diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index f2f6231185b..a94742aad70 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -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 diff --git a/docs/generated/postgres-schema/public.project.md b/docs/generated/postgres-schema/public.project.md index 9302c8827dd..1b5aa473f28 100644 --- a/docs/generated/postgres-schema/public.project.md +++ b/docs/generated/postgres-schema/public.project.md @@ -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 diff --git a/docs/generated/postgres-schema/public.self_healing_configuration.md b/docs/generated/postgres-schema/public.self_healing_configuration.md index 9533e5edcfc..88eadc82871 100644 --- a/docs/generated/postgres-schema/public.self_healing_configuration.md +++ b/docs/generated/postgres-schema/public.self_healing_configuration.md @@ -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 diff --git a/docs/generated/postgres-schema/public.self_healing_investigation.md b/docs/generated/postgres-schema/public.self_healing_investigation.md index 6abb7cc8317..7d092ff1ec6 100644 --- a/docs/generated/postgres-schema/public.self_healing_investigation.md +++ b/docs/generated/postgres-schema/public.self_healing_investigation.md @@ -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 diff --git a/docs/generated/postgres-schema/public.self_healing_result.md b/docs/generated/postgres-schema/public.self_healing_result.md index 21d0d46c8ce..3ca3b8ab9b4 100644 --- a/docs/generated/postgres-schema/public.self_healing_result.md +++ b/docs/generated/postgres-schema/public.self_healing_result.md @@ -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 diff --git a/docs/generated/postgres-schema/public.user.md b/docs/generated/postgres-schema/public.user.md index ead0a66a5b0..86a26c11722 100644 --- a/docs/generated/postgres-schema/public.user.md +++ b/docs/generated/postgres-schema/public.user.md @@ -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 diff --git a/docs/generated/postgres-schema/public.workflow_entity.md b/docs/generated/postgres-schema/public.workflow_entity.md index 5ed1b41989d..61df85f1a7d 100644 --- a/docs/generated/postgres-schema/public.workflow_entity.md +++ b/docs/generated/postgres-schema/public.workflow_entity.md @@ -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 diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 882e2201184..f4671ef57e8 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -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 diff --git a/docs/generated/sqlite-schema/project.md b/docs/generated/sqlite-schema/project.md index 49c73388d3d..4ebbd709e84 100644 --- a/docs/generated/sqlite-schema/project.md +++ b/docs/generated/sqlite-schema/project.md @@ -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 diff --git a/docs/generated/sqlite-schema/self_healing_configuration.md b/docs/generated/sqlite-schema/self_healing_configuration.md index ef51466bd61..cc74a76151a 100644 --- a/docs/generated/sqlite-schema/self_healing_configuration.md +++ b/docs/generated/sqlite-schema/self_healing_configuration.md @@ -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 diff --git a/docs/generated/sqlite-schema/self_healing_investigation.md b/docs/generated/sqlite-schema/self_healing_investigation.md index d73d24e106f..82674740d6c 100644 --- a/docs/generated/sqlite-schema/self_healing_investigation.md +++ b/docs/generated/sqlite-schema/self_healing_investigation.md @@ -6,7 +6,7 @@ Table Definition ```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) ``` @@ -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 diff --git a/docs/generated/sqlite-schema/self_healing_result.md b/docs/generated/sqlite-schema/self_healing_result.md index 4e7f5f61f33..735eaf5b8fc 100644 --- a/docs/generated/sqlite-schema/self_healing_result.md +++ b/docs/generated/sqlite-schema/self_healing_result.md @@ -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 diff --git a/docs/generated/sqlite-schema/user.md b/docs/generated/sqlite-schema/user.md index 02a92736d10..6e123e2f829 100644 --- a/docs/generated/sqlite-schema/user.md +++ b/docs/generated/sqlite-schema/user.md @@ -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 diff --git a/docs/generated/sqlite-schema/workflow_entity.md b/docs/generated/sqlite-schema/workflow_entity.md index 045e86e55f5..311493cb49d 100644 --- a/docs/generated/sqlite-schema/workflow_entity.md +++ b/docs/generated/sqlite-schema/workflow_entity.md @@ -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 diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index f19d193053c..ac84770afb7 100644 --- a/packages/@n8n/ai-utilities/package.json +++ b/packages/@n8n/ai-utilities/package.json @@ -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", diff --git a/packages/@n8n/ai-utilities/src/__tests__/utils/n8n-llm-tracing.test.ts b/packages/@n8n/ai-utilities/src/__tests__/utils/n8n-llm-tracing.test.ts index b061107c166..eaaddbba0cf 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/utils/n8n-llm-tracing.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/utils/n8n-llm-tracing.test.ts @@ -1101,6 +1101,108 @@ describe('N8nLlmTracing', () => { const tracingArg = setMetadataMock.mock.calls[0][0].tracing as Record; 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 | undefined, + output: LLMResult, + tracerOptions?: ConstructorParameters[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', () => { diff --git a/packages/@n8n/ai-utilities/src/gen-ai/attributes.ts b/packages/@n8n/ai-utilities/src/gen-ai/attributes.ts new file mode 100644 index 00000000000..175d10be9a1 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/gen-ai/attributes.ts @@ -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; + +const LANGCHAIN_NODE_PREFIX = '@n8n/n8n-nodes-langchain.'; + +const PROVIDER_BY_CHAT_MODEL_NODE: Readonly> = { + 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 = 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 }), + }; +} diff --git a/packages/@n8n/ai-utilities/src/gen-ai/index.ts b/packages/@n8n/ai-utilities/src/gen-ai/index.ts new file mode 100644 index 00000000000..1e32fe6d5e4 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/gen-ai/index.ts @@ -0,0 +1,7 @@ +export { + GEN_AI_ATTR, + N8N_USAGE_REPORTED_ATTR, + buildChatGenAiAttributes, + isGenAiAttributeKey, +} from './attributes'; +export type { GenAiAttributes, GenAiProviderName } from './attributes'; diff --git a/packages/@n8n/ai-utilities/src/utils/n8n-llm-tracing.ts b/packages/@n8n/ai-utilities/src/utils/n8n-llm-tracing.ts index 8cff56386ee..215b54d124c 100644 --- a/packages/@n8n/ai-utilities/src/utils/n8n-llm-tracing.ts +++ b/packages/@n8n/ai-utilities/src/utils/n8n-llm-tracing.ts @@ -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, + _tags?: string[], + metadata?: Record, + ) { 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, + 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; diff --git a/packages/@n8n/api-types/src/dto/credentials/credential-public.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credential-public.dto.ts index 3f0f03f2e5b..aab85e1ce34 100644 --- a/packages/@n8n/api-types/src/dto/credentials/credential-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/credentials/credential-public.dto.ts @@ -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, diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 4a066c97f67..0224a704dbd 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -835,9 +835,11 @@ export { export { parsePromotionRunDetails, promotionGitRunDetailsSchema, + promotionRunContentsSchema, promotionRunDetailsSchemas, promotionRunTypeSchema, type PromotionGitRunDetails, + type PromotionRunContents, type PromotionRunDetails, type PromotionRunType, } from './promotions/promotion-run.dto'; diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-run.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-run.dto.test.ts index 9af17489ae0..9cd8a976078 100644 --- a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-run.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-run.dto.test.ts @@ -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', () => { diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-preview.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-preview.dto.ts index 7a7b17e64e7..3323430c0a6 100644 --- a/packages/@n8n/api-types/src/dto/promotions/promotion-preview.dto.ts +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-preview.dto.ts @@ -69,9 +69,18 @@ export const promotionResourceDependencySchema = z.object({ export type PromotionResourceDependency = z.infer; +/** 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; + export const promotionPreviewSchema = promotionChangesSchema.extend({ resources: z.array(promotionResourceSchema), dependencies: z.array(promotionResourceDependencySchema), + blocker: promotionBlockerSchema.optional(), }); export type PromotionPreview = z.infer; diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-run.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-run.dto.ts index 3939f4f85ed..06afd28f8d6 100644 --- a/packages/@n8n/api-types/src/dto/promotions/promotion-run.dto.ts +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-run.dto.ts @@ -7,15 +7,28 @@ import { z } from 'zod'; export const promotionRunTypeSchema = z.enum(['git']); export type PromotionRunType = z.infer; +/** + * 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; + /** * 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; diff --git a/packages/@n8n/api-types/src/self-healing-results.ts b/packages/@n8n/api-types/src/self-healing-results.ts index d16fec01e92..3066bbb9497 100644 --- a/packages/@n8n/api-types/src/self-healing-results.ts +++ b/packages/@n8n/api-types/src/self-healing-results.ts @@ -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; diff --git a/packages/@n8n/db/src/migrations/common/1792750863544-ReplaceSelfHealingInvestigationBaseline.ts b/packages/@n8n/db/src/migrations/common/1792750863544-ReplaceSelfHealingInvestigationBaseline.ts new file mode 100644 index 00000000000..7d14792acd7 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1792750863544-ReplaceSelfHealingInvestigationBaseline.ts @@ -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 }, + ); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1792743375714-AddAgentVersionMetadata.ts b/packages/@n8n/db/src/migrations/common/1792769938200-AddAgentVersionMetadata.ts similarity index 90% rename from packages/@n8n/db/src/migrations/common/1792743375714-AddAgentVersionMetadata.ts rename to packages/@n8n/db/src/migrations/common/1792769938200-AddAgentVersionMetadata.ts index 1b207a9899b..131f77363d3 100644 --- a/packages/@n8n/db/src/migrations/common/1792743375714-AddAgentVersionMetadata.ts +++ b/packages/@n8n/db/src/migrations/common/1792769938200-AddAgentVersionMetadata.ts @@ -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']) { diff --git a/packages/@n8n/db/src/migrations/common/1792743375715-CreateAgentPublishHistory.ts b/packages/@n8n/db/src/migrations/common/1792769938398-CreateAgentPublishHistory.ts similarity index 97% rename from packages/@n8n/db/src/migrations/common/1792743375715-CreateAgentPublishHistory.ts rename to packages/@n8n/db/src/migrations/common/1792769938398-CreateAgentPublishHistory.ts index b7cceb3f5fc..6015557754a 100644 --- a/packages/@n8n/db/src/migrations/common/1792743375715-CreateAgentPublishHistory.ts +++ b/packages/@n8n/db/src/migrations/common/1792769938398-CreateAgentPublishHistory.ts @@ -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. diff --git a/packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts b/packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts new file mode 100644 index 00000000000..d19ec7d99e1 --- /dev/null +++ b/packages/@n8n/engine/src/auth/__tests__/resume-token.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/engine/src/auth/index.ts b/packages/@n8n/engine/src/auth/index.ts index 3977f3f6507..42b3708494a 100644 --- a/packages/@n8n/engine/src/auth/index.ts +++ b/packages/@n8n/engine/src/auth/index.ts @@ -14,3 +14,4 @@ export { SharedSecretIdentityVerifier, } from './identity-token'; export type { AuthenticatedCaller, IdentityVerifier } from './identity.types'; +export { mintResumeToken, RESUME_TOKEN, verifyResumeToken } from './resume-token'; diff --git a/packages/@n8n/engine/src/auth/resume-token.ts b/packages/@n8n/engine/src/auth/resume-token.ts new file mode 100644 index 00000000000..506f655c07e --- /dev/null +++ b/packages/@n8n/engine/src/auth/resume-token.ts @@ -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; +} diff --git a/packages/@n8n/engine/src/auth/shared-secret-token.ts b/packages/@n8n/engine/src/auth/shared-secret-token.ts index 3cc6afb4288..72eaf376ae0 100644 --- a/packages/@n8n/engine/src/auth/shared-secret-token.ts +++ b/packages/@n8n/engine/src/auth/shared-secret-token.ts @@ -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 & { + 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( - spec: SharedSecretTokenSpec, + spec: SharedSecretTokenSpec | UnexpiringTokenSpec, secret: string, token: string, schema: z.ZodType, @@ -54,7 +59,7 @@ export function verifySharedSecretToken( algorithms: ['HS256'], issuer: spec.issuer, audience: spec.audience, - maxAge: spec.ttlSeconds, + ...(spec.ttlSeconds === null ? {} : { maxAge: spec.ttlSeconds }), clockTolerance: spec.clockToleranceSeconds, clockTimestamp: now, }); diff --git a/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts b/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts index 3a18caf4b34..7b782ea562b 100644 --- a/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts +++ b/packages/@n8n/engine/src/database/__tests__/workflow-step-execution.integration.test.ts @@ -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, }); diff --git a/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts index 8970cf033fd..3d4ffb39d22 100644 --- a/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/step-execution.integration.test.ts @@ -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) => { diff --git a/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts b/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts index 021b0f65919..2b73d6bd235 100644 --- a/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts +++ b/packages/@n8n/engine/src/execution/__tests__/step-ready-handler.test.ts @@ -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(); diff --git a/packages/@n8n/engine/src/execution/execution.types.ts b/packages/@n8n/engine/src/execution/execution.types.ts index 23358ec46cf..c341a099639 100644 --- a/packages/@n8n/engine/src/execution/execution.types.ts +++ b/packages/@n8n/engine/src/execution/execution.types.ts @@ -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 + ); } /** diff --git a/packages/@n8n/engine/src/execution/index.ts b/packages/@n8n/engine/src/execution/index.ts index 9ff62d3430a..4bcc4543f07 100644 --- a/packages/@n8n/engine/src/execution/index.ts +++ b/packages/@n8n/engine/src/execution/index.ts @@ -13,6 +13,7 @@ export type { StepError, StepKey, StepKeyId, + ResumeBinding, ResumeCause, StepSlots, StepStatus, diff --git a/packages/@n8n/engine/src/execution/step-ready-handler.ts b/packages/@n8n/engine/src/execution/step-ready-handler.ts index 2d52fa16b43..c7570bf4d6c 100644 --- a/packages/@n8n/engine/src/execution/step-ready-handler.ts +++ b/packages/@n8n/engine/src/execution/step-ready-handler.ts @@ -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`, ); } diff --git a/packages/@n8n/engine/src/index.ts b/packages/@n8n/engine/src/index.ts index 15bbe18f08e..dbe9a4dac52 100644 --- a/packages/@n8n/engine/src/index.ts +++ b/packages/@n8n/engine/src/index.ts @@ -10,6 +10,7 @@ export { InvalidIdentityTokenError, mintActionToken, mintIdentityToken, + mintResumeToken, SharedSecretIdentityVerifier, verifyActionToken, } from './auth'; diff --git a/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts b/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts index 2da0bd887dc..1dbb8dc7c18 100644 --- a/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts +++ b/packages/@n8n/engine/src/server/__tests__/workflow-executions.integration.test.ts @@ -830,7 +830,7 @@ describe('POST /api/workflow-executions/:id/cancel (integration)', () => { nodeId: 'b', iteration: 0, status: 'waiting', - waitDeclaration: { acceptsResumeRequest: true }, + waitDeclaration: { resumeBinding: 'execution' }, }), ); diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts index d86dcac9ba3..79b549c9818 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index 357543604d0..71d708d0dff 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -765,6 +765,7 @@ export type { LocalMcpServer, McpServerConfig, ModelConfig, + EndpointModelConfig, VertexAnthropicModelConfig, InstanceAiMemoryConfig, CreateInstanceAgentOptions, diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/classify-node-destructiveness.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/classify-node-destructiveness.service.test.ts index 0f0b6ad9fa2..09205462115 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/classify-node-destructiveness.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/classify-node-destructiveness.service.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/generate-simulation-fixtures.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/generate-simulation-fixtures.service.test.ts index e2eb980dd69..28e39bc1dcc 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/generate-simulation-fixtures.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/generate-simulation-fixtures.service.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/plan-verification-simulation.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/plan-verification-simulation.test.ts index d4b7eb7079e..793a38881dd 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/plan-verification-simulation.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/plan-verification-simulation.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index d308d5aee3f..aa703e0fad4 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -2043,8 +2043,19 @@ export interface InstanceAiMemoryConfig { type NativeLanguageModelConfig = Extract; +/** 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; + 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 } + | EndpointModelConfig | VertexAnthropicModelConfig | NativeLanguageModelConfig; diff --git a/packages/@n8n/instance-ai/src/utils/__tests__/modal-session.test.ts b/packages/@n8n/instance-ai/src/utils/__tests__/modal-session.test.ts index 9d03424e64c..01ec953d7e8 100644 --- a/packages/@n8n/instance-ai/src/utils/__tests__/modal-session.test.ts +++ b/packages/@n8n/instance-ai/src/utils/__tests__/modal-session.test.ts @@ -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', diff --git a/packages/@n8n/instance-ai/src/utils/modal-session.ts b/packages/@n8n/instance-ai/src/utils/modal-session.ts index bac50293f32..11741c5cfee 100644 --- a/packages/@n8n/instance-ai/src/utils/modal-session.ts +++ b/packages/@n8n/instance-ai/src/utils/modal-session.ts @@ -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; - 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 { diff --git a/packages/@n8n/node-engine-compatibility/src/__tests__/v1-step-executor.test.ts b/packages/@n8n/node-engine-compatibility/src/__tests__/v1-step-executor.test.ts index 2b81b63a4e0..72220617dc1 100644 --- a/packages/@n8n/node-engine-compatibility/src/__tests__/v1-step-executor.test.ts +++ b/packages/@n8n/node-engine-compatibility/src/__tests__/v1-step-executor.test.ts @@ -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); diff --git a/packages/@n8n/node-engine-compatibility/src/v1-step-executor.ts b/packages/@n8n/node-engine-compatibility/src/v1-step-executor.ts index 93c49a9afe1..dc4479c4b12 100644 --- a/packages/@n8n/node-engine-compatibility/src/v1-step-executor.ts +++ b/packages/@n8n/node-engine-compatibility/src/v1-step-executor.ts @@ -75,7 +75,7 @@ function toStepResult( wait: { resumeAt: waitTill.toISOString(), outputsAtDeadline: toStepOutputs([context.getInputData()]), - acceptsResumeRequest: context.acceptsResumeRequest, + ...(context.acceptsResumeRequest ? { resumeBinding: 'execution' as const } : {}), }, }; } diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts index 08fec421e72..8b5dbaca9c2 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts @@ -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'), diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/N8nLlmTracing.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/N8nLlmTracing.test.ts index 3cb82871f64..ce2351f87c7 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/test/N8nLlmTracing.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/N8nLlmTracing.test.ts @@ -368,6 +368,7 @@ describe('N8nLlmTracing', () => { index: 0, options: { model: 'test-model' }, messages: prompts, + isChatModel: false, }); expect(executionFunctions.addInputData).toHaveBeenCalledWith( 'ai_languageModel', diff --git a/packages/@n8n/task-runner-python/pyproject.toml b/packages/@n8n/task-runner-python/pyproject.toml index 87c2a8490be..f35512d959a 100644 --- a/packages/@n8n/task-runner-python/pyproject.toml +++ b/packages/@n8n/task-runner-python/pyproject.toml @@ -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 = [ diff --git a/packages/@n8n/task-runner-python/uv.lock b/packages/@n8n/task-runner-python/uv.lock index c55375ed5a8..bbd4c8c8548 100644 --- a/packages/@n8n/task-runner-python/uv.lock +++ b/packages/@n8n/task-runner-python/uv.lock @@ -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]] diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model.service.test.ts index 3b48fc9bf67..88b99e9d38e 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-model.service.test.ts @@ -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', diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts index b668fe3a89c..1343c37c127 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-settings.service.test.ts @@ -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())).resolves.toEqual({ + kind: 'endpoint', ...expected, url, apiKey: 'admin-key', @@ -1457,6 +1459,7 @@ describe('InstanceAiSettingsService', () => { }); await expect(service.resolveModelConfig(mock())).resolves.toEqual({ + kind: 'endpoint', id: 'openai/gpt-5', url: '', apiKey: 'environment-key', @@ -1484,6 +1487,7 @@ describe('InstanceAiSettingsService', () => { }); await expect(service.resolveModelConfig(mock())).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())).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())).resolves.toEqual({ + kind: 'endpoint', id: 'openai/gpt-4', url: '', apiKey: 'model-key', @@ -2053,6 +2069,7 @@ describe('InstanceAiSettingsService', () => { }); await expect(service.resolveModelConfig(mock())).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())).resolves.toEqual({ + kind: 'vertex-anthropic', id: 'google-vertex-anthropic/claude-opus-4-8', project: 'from-json', location: 'global', diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-verification.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-verification.service.test.ts index d2c84e07d8c..2f3e0fd6ebc 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-verification.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-verification.service.test.ts @@ -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); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 13ad56897a5..7f24a232f6b 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -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', diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index 546207bf2ce..a1e82bc833e 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -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', diff --git a/packages/cli/src/modules/instance-ai/__tests__/model-config-name.test.ts b/packages/cli/src/modules/instance-ai/__tests__/model-config-name.test.ts index ff9907c81f4..548366a9db5 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/model-config-name.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/model-config-name.test.ts @@ -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', diff --git a/packages/cli/src/modules/instance-ai/__tests__/observability.test.ts b/packages/cli/src/modules/instance-ai/__tests__/observability.test.ts index 36d128c957c..85b82cedaa6 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/observability.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/observability.test.ts @@ -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', }), diff --git a/packages/cli/src/modules/instance-ai/instance-ai-model.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-model.service.ts index 1639351dee7..f004eee61c3 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-model.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-model.service.ts @@ -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'); diff --git a/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts index 5e2d5f6e6c3..14d64adc9e1 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-settings.service.ts @@ -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(), diff --git a/packages/cli/src/modules/instance-ai/model-config-name.ts b/packages/cli/src/modules/instance-ai/model-config-name.ts index afdedae5037..f07066046a5 100644 --- a/packages/cli/src/modules/instance-ai/model-config-name.ts +++ b/packages/cli/src/modules/instance-ai/model-config-name.ts @@ -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; } /** diff --git a/packages/cli/src/modules/instance-ai/self-healing/README.md b/packages/cli/src/modules/instance-ai/self-healing/README.md index 4d8b52f11a6..374b5a51652 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/README.md +++ b/packages/cli/src/modules/instance-ai/self-healing/README.md @@ -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. diff --git a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-configuration.service.integration.test.ts b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-configuration.service.integration.test.ts index e4875233ef6..eb416a0bb88 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-configuration.service.integration.test.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-configuration.service.integration.test.ts @@ -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, }); diff --git a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-investigation.repository.integration.test.ts b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-investigation.repository.integration.test.ts index febf6c96a3a..aa4b4dccfe3 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-investigation.repository.integration.test.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-investigation.repository.integration.test.ts @@ -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, }); diff --git a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-investigation.service.test.ts b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-investigation.service.test.ts index 5cdb3aea528..7b19d8fb4b1 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-investigation.service.test.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-investigation.service.test.ts @@ -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() }; + txRunner.run.mockImplementation(async (_ctx, fn) => await fn(ctx)); + suggestions.readWorkflowTargetForApply.mockImplementationOnce(async () => { + resultRepository.isInCooldown.mockResolvedValue(true); + return mock>>(); + }); + + 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); diff --git a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-result.service.integration.test.ts b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-result.service.integration.test.ts index 95cd6e3b930..d05a1db3732 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-result.service.integration.test.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-result.service.integration.test.ts @@ -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 = {}, +) { 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 = {}, +) { + 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; + } = {}, ) { - 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[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>) { @@ -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 () => { diff --git a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-trigger.service.integration.test.ts b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-trigger.service.integration.test.ts index 8897bb9c6ec..b09c1e996c7 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-trigger.service.integration.test.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/__tests__/self-healing-trigger.service.integration.test.ts @@ -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({ 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(); }); }); diff --git a/packages/cli/src/modules/instance-ai/self-healing/database/self-healing-investigation.entity.ts b/packages/cli/src/modules/instance-ai/self-healing/database/self-healing-investigation.entity.ts index efe3bc4d868..8ca06943546 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/database/self-healing-investigation.entity.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/database/self-healing-investigation.entity.ts @@ -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; diff --git a/packages/cli/src/modules/instance-ai/self-healing/database/self-healing-investigation.repository.ts b/packages/cli/src/modules/instance-ai/self-healing/database/self-healing-investigation.repository.ts index 490c44c386c..41a0bdea2f2 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/database/self-healing-investigation.repository.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/database/self-healing-investigation.repository.ts @@ -48,13 +48,15 @@ export class SelfHealingInvestigationRepository extends BaseRepository '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 { constructor( @@ -93,6 +102,20 @@ export class SelfHealingResultRepository extends BaseRepository { + 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, diff --git a/packages/cli/src/modules/instance-ai/self-healing/self-healing-investigation.service.ts b/packages/cli/src/modules/instance-ai/self-healing/self-healing-investigation.service.ts index d958136fe0e..ddc9010a9e9 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/self-healing-investigation.service.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/self-healing-investigation.service.ts @@ -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) { diff --git a/packages/cli/src/modules/instance-ai/self-healing/self-healing-result.service.ts b/packages/cli/src/modules/instance-ai/self-healing/self-healing-result.service.ts index 93a43e413a0..4d796a6ea51 100644 --- a/packages/cli/src/modules/instance-ai/self-healing/self-healing-result.service.ts +++ b/packages/cli/src/modules/instance-ai/self-healing/self-healing-result.service.ts @@ -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, + result: Pick, suggestion: Pick | 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; diff --git a/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts b/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts index 329ec00b590..f9cb1074cb7 100644 --- a/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts +++ b/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts @@ -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', () => { diff --git a/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts b/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts index 88b72bfc073..60b127849fb 100644 --- a/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts +++ b/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts @@ -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); (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), ); 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, + ) => ({ + startTime: 0, + executionTime: 1, + executionIndex, + source, + data: {}, + metadata: tracing ? { tracing } : undefined, + }); + + const chatCall = ( + executionIndex: number, + tracing: Record, + 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) => { + 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( diff --git a/packages/cli/src/modules/otel/execution-level-tracer.ts b/packages/cli/src/modules/otel/execution-level-tracer.ts index 1a0ee37a3d0..d1201c834ae 100644 --- a/packages/cli/src/modules/otel/execution-level-tracer.ts +++ b/packages/cli/src/modules/otel/execution-level-tracer.ts @@ -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) { diff --git a/packages/cli/src/modules/otel/execution-level-tracer.types.ts b/packages/cli/src/modules/otel/execution-level-tracer.types.ts index 8431fa71f14..f54c49dbfd6 100644 --- a/packages/cli/src/modules/otel/execution-level-tracer.types.ts +++ b/packages/cli/src/modules/otel/execution-level-tracer.types.ts @@ -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; + genAiAttributes?: GenAiAttributes; }; diff --git a/packages/cli/src/modules/otel/gen-ai-attributes.ts b/packages/cli/src/modules/otel/gen-ai-attributes.ts new file mode 100644 index 00000000000..95049665d9a --- /dev/null +++ b/packages/cli/src/modules/otel/gen-ai-attributes.ts @@ -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 }), + }; +} diff --git a/packages/cli/src/modules/otel/otel-lifecycle-handler.ts b/packages/cli/src/modules/otel/otel-lifecycle-handler.ts index d6837e2cc73..e01bd159ef6 100644 --- a/packages/cli/src/modules/otel/otel-lifecycle-handler.ts +++ b/packages/cli/src/modules/otel/otel-lifecycle-handler.ts @@ -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)]), ); } } diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-change-gate.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-change-gate.test.ts new file mode 100644 index 00000000000..1649738924e --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-change-gate.test.ts @@ -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({ + role: mock({ scopes: scopes.map((slug) => mock({ slug })) }), + }); +const pusher = userWith('gitConnection:push'); +const puller = userWith('gitConnection:pull'); +const nobody = userWith(); + +const branched = (createBranchOnPromotion: boolean) => + mock({ + environmentId: 'env-1', + config: { + direction: 'promote', + settings: { schemaVersion: 1, baseBranchName: 'staging', createBranchOnPromotion }, + }, + }); + +describe('PromotionChangeService.getPreview review gate', () => { + const promotionsService = mock(); + const environmentResolver = mock(); + const reviewGate = mock(); + const logger = mock(); + logger.scoped.mockReturnValue(logger); + + const service = new PromotionChangeService( + promotionsService, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + 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(); + }); +}); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-review-gate.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-review-gate.service.test.ts new file mode 100644 index 00000000000..affc69f2e4b --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-review-gate.service.test.ts @@ -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(); + const reviewRepository = mock(); + const runRepository = mock(); + const logger = mock(); + logger.scoped.mockReturnValue(logger); + + const service = new PromotionReviewGateService(inbox, reviewRepository, runRepository, logger); + + const review = (id: string) => mock({ 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(['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(['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(); + }, + ); +}); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-review-inbox.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-review-inbox.service.test.ts new file mode 100644 index 00000000000..804182a37c2 --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-review-inbox.service.test.ts @@ -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(); + const runRepository = mock(); + const hostResolver = mock(); + const mergeRequests = mock(); + + const logger = mock(); + logger.scoped.mockReturnValue(logger); + + const service = new PromotionReviewInboxService( + mock(), + reviewRepository, + runRepository, + hostResolver, + mergeRequests, + logger, + ); + + const review = (overrides: Partial = {}) => + mock({ + id: 'review-1', + type: 'gitlab', + state: 'open', + remoteReviewId: '7!3', + environment: mock({ id: 'env-1' }), + ...overrides, + }); + const mergeRequest = (overrides: Partial = {}) => + ({ + 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((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); + }); +}); diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts index da41b5df64f..6832d0b20db 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions-package.integration.test.ts @@ -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', { diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts index 5ef534d672e..df8eb96c075 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotions.service.test.ts @@ -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 () => { diff --git a/packages/cli/src/modules/promotions.ee/database/repositories/promotion-review.repository.ts b/packages/cli/src/modules/promotions.ee/database/repositories/promotion-review.repository.ts index 59878764ae0..b0fc9efbf52 100644 --- a/packages/cli/src/modules/promotions.ee/database/repositories/promotion-review.repository.ts +++ b/packages/cli/src/modules/promotions.ee/database/repositories/promotion-review.repository.ts @@ -47,6 +47,15 @@ export class PromotionReviewRepository extends BaseRepository { 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 { + 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 { const result = await this.update( diff --git a/packages/cli/src/modules/promotions.ee/git-hosts/__tests__/gitlab-merge-request.client.test.ts b/packages/cli/src/modules/promotions.ee/git-hosts/__tests__/gitlab-merge-request.client.test.ts index 69f4a5b062a..63856a63c6f 100644 --- a/packages/cli/src/modules/promotions.ee/git-hosts/__tests__/gitlab-merge-request.client.test.ts +++ b/packages/cli/src/modules/promotions.ee/git-hosts/__tests__/gitlab-merge-request.client.test.ts @@ -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); diff --git a/packages/cli/src/modules/promotions.ee/git-hosts/gitlab-merge-request.client.ts b/packages/cli/src/modules/promotions.ee/git-hosts/gitlab-merge-request.client.ts index 23f86d18faf..c3067d8e51a 100644 --- a/packages/cli/src/modules/promotions.ee/git-hosts/gitlab-merge-request.client.ts +++ b/packages/cli/src/modules/promotions.ee/git-hosts/gitlab-merge-request.client.ts @@ -67,6 +67,7 @@ type ApiRequest = { path: string; qs?: IDataObject; body?: IDataObject; + timeoutMs?: number; }; /** Stores one merge request as `!`. 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 { 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> { let response: TypedHttpFullResponse; 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, diff --git a/packages/cli/src/modules/promotions.ee/promotion-change.service.ts b/packages/cli/src/modules/promotions.ee/promotion-change.service.ts index 0cd3619e480..e51584f89dc 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-change.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-change.service.ts @@ -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 { + 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 = { diff --git a/packages/cli/src/modules/promotions.ee/promotion-review-gate.service.ts b/packages/cli/src/modules/promotions.ee/promotion-review-gate.service.ts new file mode 100644 index 00000000000..ee402ac99ec --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/promotion-review-gate.service.ts @@ -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(); + 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 { + 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 { + 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; + } + } +} diff --git a/packages/cli/src/modules/promotions.ee/promotion-review-inbox.service.ts b/packages/cli/src/modules/promotions.ee/promotion-review-inbox.service.ts index 7a6911fa276..a23657a1828 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-review-inbox.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-review-inbox.service.ts @@ -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>(); + 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 { + 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 { + 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> { const runs = await this.runRepository.findByReviewIds(reviews.map(({ id }) => id)); const newest = new Map(); - 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 { 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); diff --git a/packages/cli/src/modules/promotions.ee/promotions.service.ts b/packages/cli/src/modules/promotions.ee/promotions.service.ts index f2082a4128a..b53ef0b8f7f 100644 --- a/packages/cli/src/modules/promotions.ee/promotions.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotions.service.ts @@ -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> { 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] }, })), }; } diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-export.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-export.service.test.ts index 808e8c7369e..bea9a056867 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-export.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-export.service.test.ts @@ -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({ @@ -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(); 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(); + 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({ 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(); + expect(fsWriteFile).toHaveBeenCalledWith('/mock/n8n/git/tags.json', dataCaptor); + expect(JSON.parse(dataCaptor.value)).toEqual({ + tags: [{ id: localTag.id, name: localTag.name }], + mappings: [validMapping], + }); }); }); diff --git a/packages/cli/src/modules/source-control.ee/source-control-export.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-export.service.ee.ts index 52e88d35804..875ee36ca0c 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-export.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-export.service.ee.ts @@ -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 }], }; diff --git a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts index 420c7bbac70..befbde748e5 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts @@ -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. diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 4a8696b8ddb..47963a5ea30 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -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[]; } diff --git a/packages/cli/src/public-api/v1/handlers/credentials/__tests__/credentials.utils.test.ts b/packages/cli/src/public-api/v1/handlers/credentials/__tests__/credentials.utils.test.ts index da973917ae1..a3aa6720337 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/__tests__/credentials.utils.test.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/__tests__/credentials.utils.test.ts @@ -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); diff --git a/packages/cli/src/public-api/v1/handlers/credentials/credentials.utils.ts b/packages/cli/src/public-api/v1/handlers/credentials/credentials.utils.ts index ef24cad284c..1dd7645270a 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/credentials.utils.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/credentials.utils.ts @@ -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) { diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/createCredential.generated.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/createCredential.generated.yml index 26524e5949f..5667f474540 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/createCredential.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/createCredential.generated.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/updateCredential.generated.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/updateCredential.generated.yml index e53558daeaa..9a7fe1482af 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/updateCredential.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/credentials/spec/paths/updateCredential.generated.yml @@ -35,7 +35,7 @@ requestBody: data: type: object additionalProperties: {} - 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 "=". writeOnly: true example: accessToken: new_token_value diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 2b0eab1decb..513ed850bfe 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -24,58 +24,85 @@ servers: default: https://example.com tags: - name: Audit + x-displayName: Audit description: Operations about security audit - name: CommunityPackage + x-displayName: Community Package description: Operations about community packages - name: Credential + x-displayName: Credential description: Operations about credentials - name: CredentialTypePolicy + x-displayName: Credential Type Policy description: Preview — breaking changes may still occur without major version bump. - name: DataTable + x-displayName: Data Table description: Operations about data tables and their rows - name: Discover + x-displayName: Discover description: API capability discovery - name: Evaluation + x-displayName: Evaluation description: Operations about evaluation test runs - name: Executions + x-displayName: Executions description: Operations about executions - name: Folders + x-displayName: Folders description: Operations about folders - name: Insights + x-displayName: Insights description: Operations about insights - name: LogStreaming + x-displayName: Log Streaming description: Operations about log streaming destinations - name: N8nPackage + x-displayName: n8n Package description: Beta — breaking changes may still occur without major version bump. - name: NodeTypePolicy + x-displayName: Node Type Policy description: Preview — breaking changes may still occur without major version bump. - name: Projects + x-displayName: Projects description: Operations about projects - name: Promotions + x-displayName: Promotions description: Operations about promotion providers, environments, and package operations - name: Role + x-displayName: Role description: Operations about roles - name: RoleMappingRule + x-displayName: Role Mapping Rule description: Operations about identity-provider role-mapping rules - name: SecurityPolicy + x-displayName: Security Policy description: Operations about the instance security policy settings - name: SettingsLdap + x-displayName: Settings LDAP description: Operations about LDAP settings - name: SettingsOtel + x-displayName: Settings OTel description: Operations about OpenTelemetry settings - name: SettingsSsoOidc + x-displayName: Settings SSO OIDC description: Operations about OIDC SSO settings - name: SettingsSsoSaml + x-displayName: Settings SSO SAML description: Operations about SAML SSO settings - name: SourceControl + x-displayName: Source Control description: Operations about source control - name: Tags + x-displayName: Tags description: Operations about tags - name: User + x-displayName: User description: Operations about users - name: Variables + x-displayName: Variables description: Operations about variables - name: Workflow + x-displayName: Workflow description: Operations about workflows # Paths and components are generated from @PublicApiController routes. See openapi-gen/generate.ts. diff --git a/packages/cli/test/integration/engine-v2-webhooks.test.ts b/packages/cli/test/integration/engine-v2-webhooks.test.ts index ac9ee913c38..e6d242900ba 100644 --- a/packages/cli/test/integration/engine-v2-webhooks.test.ts +++ b/packages/cli/test/integration/engine-v2-webhooks.test.ts @@ -18,6 +18,7 @@ import { randomUUID } from 'node:crypto'; import { agent as testAgent } from 'supertest'; import { CacheService } from '@n8n/backend-services'; +import { ActiveExecutions } from '@/active-executions'; import { InMemoryExecutionResponseChannel } from '@/modules/engine-v2/response-channel/in-memory-execution-response-channel'; import { InMemoryExecutionResponseReceiver } from '@/modules/engine-v2/response-channel/in-memory-execution-response-receiver'; import { InMemoryExecutionResponseSender } from '@/modules/engine-v2/response-channel/in-memory-execution-response-sender'; @@ -245,3 +246,38 @@ describe('webhook runs on engine v2', () => { expect(response.text).toBe(`${JSON.stringify(errorChunk)}\n`); }); }); + +describe.each([ + { engine: 'with engineType v1', settings: { engineType: 'v1' as const } }, + { engine: 'with no engineType', settings: {} }, +])('test webhook runs $engine', ({ settings }) => { + test('runs the workflow on v1 and persists the execution', async () => { + const webhookId = randomUUID(); + const workflow = await createWorkflow( + { active: false, nodes: [webhookNode(webhookId)], connections: {}, settings }, + builder, + ); + + const listening = await startListening(workflow.id); + expect(listening.body.data).toEqual({ waitingForWebhook: true }); + + const response = await webhookAgent + .post(`/${webhookTestEndpoint}/${webhookId}`) + .send({ order: 42 }); + + expect(response.statusCode).toBe(200); + expect(startExecution).not.toHaveBeenCalled(); + + const executions = (await getAllExecutions()).filter((e) => e.workflowId === workflow.id); + + // Let the v1 run finish first, so a failed assertion does not leave it running into the next test. + const activeExecutions = Container.get(ActiveExecutions); + for (const { id } of executions) { + if (activeExecutions.has(id)) await activeExecutions.getPostExecutePromise(id); + } + + expect(executions).toEqual([ + expect.objectContaining({ id: expect.stringMatching(/^\d+$/), mode: 'manual' }), + ]); + }); +}); diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 8889f0d1e36..995d8f69609 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -1323,6 +1323,46 @@ describe('SourceControlImportService', () => { return await workflowTagMappingRepository.findBy({ workflowId }); } + // LIGO-1113: One missing tag must not stop valid tag links in the same import. + it('should skip mappings for missing tags and import valid mappings for all workflows', async () => { + const user = await getGlobalOwner(); + const teamProject = await createTeamProject('Team 1'); + const [workflow1, workflow2] = await Promise.all([ + createWorkflowWithHistory({ id: 'workflow-1', name: 'Workflow 1' }, teamProject), + createWorkflowWithHistory({ id: 'workflow-2', name: 'Workflow 2' }, teamProject), + ]); + + setupTagsFileMock([ + { tagId: 'tag-1', workflowId: workflow1.id }, + { tagId: 'missing-tag', workflowId: workflow1.id }, + { tagId: 'tag-2', workflowId: workflow2.id }, + ]); + + await service.importTagsFromWorkFolder(createTagsCandidate(), user); + + const workflow1Mappings = await getMappingsForWorkflow(workflow1.id); + expect(workflow1Mappings.map((mapping) => mapping.tagId)).toEqual(['tag-1']); + const workflow2Mappings = await getMappingsForWorkflow(workflow2.id); + expect(workflow2Mappings.map((mapping) => mapping.tagId)).toEqual(['tag-2']); + }); + + it('should keep mappings for local tags that are absent from the imported tags list', async () => { + const user = await getGlobalOwner(); + const teamProject = await createTeamProject('Team 1'); + const workflow = await createWorkflowWithHistory( + { id: 'workflow-1', name: 'Workflow 1' }, + teamProject, + ); + const localTag = await createTag({ id: 'local-tag', name: 'Local tag' }, workflow); + + setupTagsFileMock([{ tagId: localTag.id, workflowId: workflow.id }]); + + await service.importTagsFromWorkFolder(createTagsCandidate(), user); + + const finalMappings = await getMappingsForWorkflow(workflow.id); + expect(finalMappings.map((mapping) => mapping.tagId)).toEqual([localTag.id]); + }); + it('should remove tags from workflows when they are removed in source control', async () => { const user = await getGlobalOwner(); const teamProject = await createTeamProject('Team 1'); diff --git a/packages/cli/test/integration/public-api/credentials.test.ts b/packages/cli/test/integration/public-api/credentials.test.ts index 137981335c6..ba62bedf9e5 100644 --- a/packages/cli/test/integration/public-api/credentials.test.ts +++ b/packages/cli/test/integration/public-api/credentials.test.ts @@ -148,6 +148,17 @@ describe('POST /credentials', () => { } }); + test('should accept an expression in a number field', async () => { + const data = { host: 'ftp.example.com', port: '={{ $vars.PORT }}' }; + + const response = await authOwnerAgent + .post('/credentials') + .send({ name: 'ftp credential', type: 'ftp', data }); + + expect(response.statusCode).toBe(200); + expect(await getDecryptedCredentialData(response.body.id)).toEqual(data); + }); + test('should fail with an unknown credential type', async () => { const response = await authOwnerAgent.post('/credentials').send({ name: 'test credential', diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index e727031d07f..77ff5249e82 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -39,12 +39,15 @@ import { DateTime } from 'luxon'; import { PROJECT_ROOT, calculateWorkflowChecksum, + type IDataObject, type INode, type IPinData, + type ITaskData, type IWorkflowBase, } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; +import { ActiveExecutions } from '@/active-executions'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { CollaborationService } from '@/collaboration/collaboration.service'; import { EngineDataPlaneProxyService } from '@/services/engine-data-plane-proxy.service'; @@ -5049,6 +5052,10 @@ describe('POST /workflows/:workflowId/deactivate', () => { }); describe('POST /workflows/:workflowId/run', () => { + const taskData = (json: IDataObject): ITaskData[] => [ + { startTime: 0, executionTime: 0, executionIndex: 0, source: [], data: { main: [[{ json }]] } }, + ]; + test('should reject manual execution when the instance is read-only', async () => { const workflow = await createWorkflow( { @@ -5258,6 +5265,214 @@ describe('POST /workflows/:workflowId/run', () => { }), ); }); + + test('should run on the data plane for a destination node without run data', async () => { + const dbWorkflow = await createV2Workflow(); + + const response = await authOwnerAgent + .post(`/workflows/${dbWorkflow.id}/run`) + .send({ destinationNode: { nodeName: SET_NAME, mode: 'inclusive' } }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.executionId).toMatch(UUID_V7_PATTERN); + expect(startExecution).toHaveBeenCalledTimes(1); + expect(startExecution).toHaveBeenCalledWith( + objectContaining({ executionId: response.body.data.executionId, mode: 'manual' }), + ); + + const executions = await getAllExecutions(); + expect(executions.filter((e) => e.workflowId === dbWorkflow.id)).toHaveLength(0); + }); + + test('should run a dirty node again on the data plane in a partial execution', async () => { + const LAST_NAME = 'Edit Fields 2'; + // The dirty node sits before the destination: the destination always runs again. + const dbWorkflow = await createWorkflow( + { + nodes: [ + { + id: uuid(), + name: TRIGGER_NAME, + type: 'n8n-nodes-base.manualTrigger', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + { + id: uuid(), + name: SET_NAME, + type: 'n8n-nodes-base.set', + parameters: {}, + typeVersion: 3.4, + position: [200, 0], + }, + { + id: uuid(), + name: LAST_NAME, + type: 'n8n-nodes-base.set', + parameters: {}, + typeVersion: 3.4, + position: [400, 0], + }, + ], + connections: { + [TRIGGER_NAME]: { main: [[{ node: SET_NAME, type: 'main', index: 0 }]] }, + [SET_NAME]: { main: [[{ node: LAST_NAME, type: 'main', index: 0 }]] }, + }, + settings: { engineType: 'v2' }, + }, + owner, + ); + + const response = await authOwnerAgent.post(`/workflows/${dbWorkflow.id}/run`).send({ + destinationNode: { nodeName: LAST_NAME, mode: 'inclusive' }, + runData: { + [TRIGGER_NAME]: taskData({ from: 'earlier' }), + [SET_NAME]: taskData({ stale: true }), + }, + dirtyNodeNames: [SET_NAME], + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.executionId).toMatch(UUID_V7_PATTERN); + expect(startExecution).toHaveBeenCalledTimes(1); + const request = startExecution.mock.calls[0][0]; + expect(request.triggerOutputs).toEqual([[{ json: { from: 'earlier' } }]]); + // The dirty node runs again, so its stale run data is not seeded. Without + // `dirtyNodeNames` it would be seeded, and only the destination would run. + expect(request.seededSteps).toEqual({}); + + const executions = await getAllExecutions(); + expect(executions.filter((e) => e.workflowId === dbWorkflow.id)).toHaveLength(0); + }); + + test('should send pinned trigger data to the data plane', async () => { + const dbWorkflow = await createV2Workflow({ [TRIGGER_NAME]: [{ json: { pinned: true } }] }); + + const response = await authOwnerAgent + .post(`/workflows/${dbWorkflow.id}/run`) + .send({ triggerToStartFrom: { name: TRIGGER_NAME } }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.executionId).toMatch(UUID_V7_PATTERN); + expect(startExecution).toHaveBeenCalledTimes(1); + expect(startExecution).toHaveBeenCalledWith( + objectContaining({ triggerOutputs: [[{ json: { pinned: true } }]] }), + ); + + const executions = await getAllExecutions(); + expect(executions.filter((e) => e.workflowId === dbWorkflow.id)).toHaveLength(0); + }); + }); + + describe.each([ + { engine: 'with engineType v1', settings: { engineType: 'v1' as const } }, + { engine: 'with no engineType', settings: {} }, + ])('$engine', ({ settings }) => { + const TRIGGER_NAME = 'When clicking Execute'; + const SET_NAME = 'Edit Fields'; + const LAST_NAME = 'Edit Fields 2'; + + const startExecution = vi.fn(); + + beforeAll(() => { + // The proxy keeps the last provider, so this describe asserts on its own stub. + Container.get(EngineDataPlaneProxyService).registerProvider({ + startExecution, + getExecution: vi.fn(), + searchExecutions: vi.fn().mockResolvedValue({ items: [], nextCursor: null, total: 0 }), + findLatestExecution: vi.fn(), + cancelExecution: vi.fn(), + }); + }); + + // The editor always saves `pinData` as an object, so the fixture does too. + const createV1Workflow = async (pinData: IPinData = {}) => + await createWorkflow( + { + pinData, + nodes: [ + { + id: uuid(), + name: TRIGGER_NAME, + type: 'n8n-nodes-base.manualTrigger', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + { + id: uuid(), + name: SET_NAME, + type: 'n8n-nodes-base.set', + parameters: {}, + typeVersion: 3.4, + position: [200, 0], + }, + { + id: uuid(), + name: LAST_NAME, + type: 'n8n-nodes-base.set', + parameters: {}, + typeVersion: 3.4, + position: [400, 0], + }, + ], + connections: { + [TRIGGER_NAME]: { main: [[{ node: SET_NAME, type: 'main', index: 0 }]] }, + [SET_NAME]: { main: [[{ node: LAST_NAME, type: 'main', index: 0 }]] }, + }, + settings, + }, + owner, + ); + + const toLastNode = { nodeName: LAST_NAME, mode: 'inclusive' }; + + test.each<{ shape: string; body: object; pinData?: IPinData }>([ + { shape: 'a known trigger', body: { triggerToStartFrom: { name: TRIGGER_NAME } } }, + { shape: 'a destination node without run data', body: { destinationNode: toLastNode } }, + { + shape: 'a destination node with run data', + body: { destinationNode: toLastNode, runData: { [TRIGGER_NAME]: taskData({}) } }, + }, + { + shape: 'a partial execution with a dirty node', + body: { + destinationNode: toLastNode, + runData: { [TRIGGER_NAME]: taskData({}), [SET_NAME]: taskData({ stale: true }) }, + dirtyNodeNames: [SET_NAME], + }, + }, + { + shape: 'a pinned trigger', + pinData: { [TRIGGER_NAME]: [{ json: { pinned: true } }] }, + body: { triggerToStartFrom: { name: TRIGGER_NAME } }, + }, + { + shape: 'a pinned node upstream of the destination', + pinData: { [SET_NAME]: [{ json: { pinned: true } }] }, + body: { destinationNode: toLastNode, runData: { [TRIGGER_NAME]: taskData({}) } }, + }, + ])('should run on v1 from $shape', async ({ body, pinData }) => { + const dbWorkflow = await createV1Workflow(pinData); + + const response = await authOwnerAgent.post(`/workflows/${dbWorkflow.id}/run`).send(body); + + expect(response.statusCode).toBe(200); + const { executionId } = response.body.data; + expect(executionId).toMatch(/^\d+$/); + + const activeExecutions = Container.get(ActiveExecutions); + if (activeExecutions.has(executionId)) { + await activeExecutions.getPostExecutePromise(executionId); + } + + const executions = await getAllExecutions(); + expect(executions.filter((e) => e.workflowId === dbWorkflow.id)).toEqual([ + objectContaining({ id: executionId, mode: 'manual' }), + ]); + expect(startExecution).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/cli/test/migration/1792750863544-replace-self-healing-investigation-baseline.test.ts b/packages/cli/test/migration/1792750863544-replace-self-healing-investigation-baseline.test.ts new file mode 100644 index 00000000000..8c4ab9e975d --- /dev/null +++ b/packages/cli/test/migration/1792750863544-replace-self-healing-investigation-baseline.test.ts @@ -0,0 +1,154 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { randomUUID } from 'node:crypto'; + +const migrationName = 'ReplaceSelfHealingInvestigationBaseline1792750863544'; + +describe('ReplaceSelfHealingInvestigationBaseline migration', () => { + const userId = randomUUID(); + const versionId = randomUUID(); + const expectedBaseline = { + savedVersionId: versionId, + publishedVersionId: versionId, + checksum: 'checksum', + versionCounter: 1, + latestPublishHistoryEventId: null, + }; + const original = { + name: 'Workflow', + pinData: { Trigger: [{ json: { value: 'before\u0000after' } }] }, + }; + let configurationId: number; + + async function withContext(operation: (context: TestMigrationContext) => Promise) { + const context = createTestMigrationContext(Container.get(DataSource)); + try { + return await operation(context); + } finally { + await context.queryRunner.release(); + } + } + + async function insertInvestigation( + id: string, + baselineColumns: Record, + resultId: string | null = null, + ) { + await withContext(async ({ escape: { tableName: t, columnName: c }, runQuery }) => { + const columns = Object.keys(baselineColumns) + .map((column) => `, ${c(column)}`) + .join(''); + const values = Object.keys(baselineColumns) + .map((column) => `, :${column}`) + .join(''); + await runQuery( + `INSERT INTO ${t('self_healing_investigation')} + (${c('id')}, ${c('configurationId')}, ${c('workflowId')}, ${c('projectId')}, ${c('backgroundUserId')}, ${c('executionId')}, ${c('threadId')}, ${c('runId')}, ${c('resultId')}, ${c('finishedAt')}${columns}) + VALUES (:id, :configurationId, 'heal-workflow', 'heal-project', :userId, :executionId, :threadId, :runId, :resultId, :finishedAt${values})`, + { + id, + configurationId, + userId, + executionId: `${id}-execution`, + threadId: randomUUID(), + runId: randomUUID(), + resultId, + finishedAt: resultId ? new Date() : null, + ...baselineColumns, + }, + ); + }); + } + + async function selectIds(table: string) { + return await withContext( + async ({ escape: { tableName: t, columnName: c }, runQuery }) => + await runQuery(`SELECT ${c('id')} FROM ${t(table)} ORDER BY ${c('id')}`), + ); + } + + beforeAll(async () => { + await Container.get(DbConnection).init(); + await withContext(async ({ queryRunner }) => await queryRunner.clearDatabase()); + await initDbUpToMigration(migrationName); + + configurationId = await withContext( + async ({ escape: { tableName: t, columnName: c }, runQuery }) => { + await runQuery(`INSERT INTO ${t('user')} (${c('id')}) VALUES (:userId)`, { userId }); + await runQuery( + `INSERT INTO ${t('project')} (${c('id')}, ${c('name')}, ${c('type')}) + VALUES ('heal-project', 'Self-healing project', 'team')`, + ); + await runQuery( + `INSERT INTO ${t('workflow_entity')} (${c('id')}, ${c('name')}, ${c('active')}, ${c('versionId')}, ${c('nodes')}, ${c('connections')}) + VALUES ('heal-workflow', 'Self-healing workflow', :active, :versionId, '[]', '{}')`, + { active: false, versionId }, + ); + await runQuery( + `INSERT INTO ${t('self_healing_configuration')} (${c('projectId')}, ${c('scope')}, ${c('enabled')}) + VALUES ('heal-project', 'all', :enabled)`, + { enabled: true }, + ); + const [configuration] = await runQuery>( + `SELECT ${c('id')} FROM ${t('self_healing_configuration')}`, + ); + await runQuery( + `INSERT INTO ${t('self_healing_result')} + (${c('id')}, ${c('configurationId')}, ${c('workflowId')}, ${c('projectId')}, ${c('backgroundUserId')}, ${c('outcome')}, ${c('summary')}, ${c('report')}, ${c('completedAt')}, ${c('executionId')}, ${c('usage')}) + VALUES ('result', :configurationId, 'heal-workflow', 'heal-project', :userId, 'could_not_fix', 'No fix.', 'The failure needs a person.', :completedAt, 'result-execution', :usage)`, + { + configurationId: configuration.id, + userId, + completedAt: new Date(), + usage: JSON.stringify({ credits: 1, turns: 1, durationSeconds: 10 }), + }, + ); + return configuration.id; + }, + ); + }); + + afterAll(async () => await Container.get(DbConnection).close()); + + it('clears investigations, keeps results, and stores the published version', async () => { + const oldBaseline = { + baseline: JSON.stringify({ + workflowId: 'heal-workflow', + projectId: 'heal-project', + backgroundUserId: userId, + expectedBaseline, + original, + }), + }; + await insertInvestigation('finished', oldBaseline, 'result'); + await insertInvestigation('unfinished', oldBaseline); + + await runSingleMigration(migrationName); + expect(await selectIds('self_healing_investigation')).toEqual([]); + expect(await selectIds('self_healing_result')).toEqual([{ id: 'result' }]); + + await insertInvestigation('replaced', { publishedVersionId: versionId }, 'result'); + await withContext(async ({ escape: { tableName: t, columnName: c }, runQuery }) => { + expect( + await runQuery(`SELECT ${c('publishedVersionId')} FROM ${t('self_healing_investigation')}`), + ).toEqual([{ publishedVersionId: versionId }]); + }); + + await undoLastSingleMigration(); + expect(await selectIds('self_healing_investigation')).toEqual([]); + expect(await selectIds('self_healing_result')).toEqual([{ id: 'result' }]); + await insertInvestigation('restored', oldBaseline); + + await runSingleMigration(migrationName); + expect(await selectIds('self_healing_investigation')).toEqual([]); + expect(await selectIds('self_healing_result')).toEqual([{ id: 'result' }]); + }); +}); diff --git a/packages/cli/test/migration/1792743375715-create-agent-publish-history.test.ts b/packages/cli/test/migration/1792769938398-create-agent-publish-history.test.ts similarity index 97% rename from packages/cli/test/migration/1792743375715-create-agent-publish-history.test.ts rename to packages/cli/test/migration/1792769938398-create-agent-publish-history.test.ts index 927366ef9f3..889138453b3 100644 --- a/packages/cli/test/migration/1792743375715-create-agent-publish-history.test.ts +++ b/packages/cli/test/migration/1792769938398-create-agent-publish-history.test.ts @@ -10,8 +10,8 @@ import { Container } from '@n8n/di'; import { DataSource } from '@n8n/typeorm'; import { randomUUID } from 'node:crypto'; -const metadataMigration = 'AddAgentVersionMetadata1792743375714'; -const publicationMigration = 'CreateAgentPublishHistory1792743375715'; +const metadataMigration = 'AddAgentVersionMetadata1792769938200'; +const publicationMigration = 'CreateAgentPublishHistory1792769938398'; describe('Agent version storage', () => { let context: TestMigrationContext; diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/execution-metadata.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/execution-metadata.test.ts index 922a7a40173..04785e0e887 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/execution-metadata.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/execution-metadata.test.ts @@ -78,6 +78,18 @@ describe('Execution Metadata functions', () => { }); }); + test('setWorkflowExecutionMetadata should convert booleans to strings', () => { + const { metadata, executionData } = createExecutionDataWithMetadata(); + + setWorkflowExecutionMetadata(executionData, 'test1', true); + setWorkflowExecutionMetadata(executionData, 'test2', false); + + expect(metadata).toEqual({ + test1: 'true', + test2: 'false', + }); + }); + test('setAllWorkflowExecutionMetadata should not convert values to strings and should set other values correctly', () => { const { metadata, executionData } = createExecutionDataWithMetadata(); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/execution-metadata.ts b/packages/core/src/execution-engine/node-execution-context/utils/execution-metadata.ts index 04b7ec936a2..83ef82b4b85 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/execution-metadata.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/execution-metadata.ts @@ -32,7 +32,12 @@ export function setWorkflowExecutionMetadata( `Custom data key can only contain letters, numbers, and underscores (key "${key}")`, ); } - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') { + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'bigint' && + typeof value !== 'boolean' + ) { throw new InvalidExecutionMetadataError('value', key); } const val = String(value); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.stories.ts index 1a2b4ec61b4..2e3b0d1c167 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.stories.ts @@ -15,6 +15,14 @@ export default { type: 'select', options: ['default', 'dark'], }, + size: { + type: 'select', + options: ['xsmall', 'small', 'medium', 'large', 'xlarge'], + }, + iconSize: { + type: 'select', + options: ['xsmall', 'small', 'medium', 'large', 'xlarge'], + }, }, parameters: { docs: { @@ -74,6 +82,34 @@ export const Variants: StoryFn = () => ({ `, }); +export const Sizes: StoryFn = () => ({ + components: { N8nActionToggle }, + setup() { + return { + actions: [ + { label: 'Go', value: 'go' }, + { label: 'Stop', value: 'stop' }, + ], + }; + }, + template: ` +
+
+ + Default: button small (28px), icon small (12px) +
+
+ + Workflow and folder cards: button small (28px), icon medium (14px) +
+
+ + NDV parameter options: button xsmall (24px), icon small (12px) +
+
+ `, +}); + export const WithDisabledTooltip = Template.bind({}); WithDisabledTooltip.args = { actions: [ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.test.ts index 0d702b94a2c..2e31cf213e7 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.test.ts @@ -29,7 +29,7 @@ describe('N8nActionToggle', () => { expect(getByText('Duplicate')).toBeInTheDocument(); expect(getByText('Delete')).toBeInTheDocument(); }); - it('defaults icon to horizontal orientation and is ellipsis', function () { + it('renders the horizontal ellipsis icon', function () { const { getByRole } = render(ActionToggle, { props: { actions: [{ label: 'Duplicate', value: 'duplicate' }], @@ -38,6 +38,48 @@ describe('N8nActionToggle', () => { expect(getByRole('button').querySelector('svg')).toHaveAttribute('data-icon', 'ellipsis-12'); }); + it('renders the icon at the default size', function () { + const { getByRole } = render(ActionToggle, { + props: { + actions: [{ label: 'Duplicate', value: 'duplicate' }], + }, + }); + + expect(getByRole('button').querySelector('svg')).toHaveAttribute('width', '12px'); + }); + it('renders the icon at the given iconSize', function () { + const { getByRole } = render(ActionToggle, { + props: { + actions: [{ label: 'Duplicate', value: 'duplicate' }], + iconSize: 'medium', + }, + }); + + expect(getByRole('button').querySelector('svg')).toHaveAttribute('width', '14px'); + }); + it('renders the button at the default size', function () { + const { getByRole } = render(ActionToggle, { + props: { + actions: [{ label: 'Duplicate', value: 'duplicate' }], + }, + }); + + const button = getByRole('button'); + expect(button.className).toMatch(/small/); + expect(button.className).not.toMatch(/xsmall/); + }); + it('renders the button at the given size and keeps the icon size', function () { + const { getByRole } = render(ActionToggle, { + props: { + actions: [{ label: 'Duplicate', value: 'duplicate' }], + size: 'xsmall', + }, + }); + + const button = getByRole('button'); + expect(button.className).toMatch(/xsmall/); + expect(button.querySelector('svg')).toHaveAttribute('width', '12px'); + }); it('should have bottom-end as default placement', async function () { const { container, getByRole } = render(ActionToggle, { props: { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.types.ts b/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.types.ts index f6ffe72604d..638e715caf8 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.types.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.types.ts @@ -1,3 +1,6 @@ +import type { ButtonSize } from '../../types/button'; +import type { IconSize } from '../../types/icon'; + /** * Extracted from the SFC: ` + + diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts index 1287ce3c03e..c686da42f79 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.test.ts @@ -40,6 +40,31 @@ const preview = (count: number): PromotionPreview => ({ })), }); +describe('usePromotionChangeCount blocker', () => { + beforeEach(() => { + vi.mocked(api.getPromotionPreview).mockReset(); + }); + + it('should keep the blocker of the preview and clear it on the next check', async () => { + const blocker = { code: 'reviewOpen' as const, reviewId: 'review-1' }; + vi.mocked(api.getPromotionPreview) + .mockResolvedValueOnce({ ...preview(0), blocker }) + .mockResolvedValueOnce(preview(1)); + const { + blocker: current, + changeCount, + refetch, + } = usePromotionChangeCount(ref('project-a'), 'promote', ref(true)); + + await waitAllPromises(); + expect(current.value).toEqual(blocker); + expect(changeCount.value).toBe(0); + + await refetch(); + expect(current.value).toBeNull(); + }); +}); + describe('usePromotionChangeCount', () => { // One entry per request, in call order, so a test can answer them out of order. let pending: Pending[]; diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.ts index b92dbe571c8..d584debfa31 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionChangeCount.ts @@ -1,5 +1,5 @@ import { ref, watch, type Ref } from 'vue'; -import type { PromotionDirection } from '@n8n/api-types'; +import type { PromotionBlocker, PromotionDirection } from '@n8n/api-types'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useLatestFetch } from '@/app/composables/useLatestFetch'; import { getPromotionPreview } from '../promotions.api'; @@ -17,6 +17,8 @@ export function usePromotionChangeCount( const changeCount = ref(0); /** True when the last check failed, so the badge can show the failure. */ const failed = ref(false); + /** Set when an open review already holds the changes. The preview then lists none. */ + const blocker = ref(null); async function fetchCount() { // Only the newest request may write, so a slow answer for a project the user left @@ -25,6 +27,7 @@ export function usePromotionChangeCount( const requestedProjectId = projectId.value; changeCount.value = 0; failed.value = false; + blocker.value = null; if (!enabled.value || !requestedProjectId) { return; } @@ -36,6 +39,7 @@ export function usePromotionChangeCount( ); if (!isLatest()) return; changeCount.value = totalChangeCount(buildSelectionGraph(preview, direction)); + blocker.value = preview.blocker ?? null; } catch { if (isLatest()) failed.value = true; } @@ -43,5 +47,5 @@ export function usePromotionChangeCount( watch([projectId, enabled], fetchCount, { immediate: true }); - return { changeCount, failed, refetch: fetchCount }; + return { changeCount, failed, blocker, refetch: fetchCount }; } diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionPreview.ts b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionPreview.ts index 560b4ab62d0..be482ffb78a 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionPreview.ts +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/composables/usePromotionPreview.ts @@ -16,6 +16,8 @@ type PromotionPreviewState = /** The instance has no environment for this direction. */ | 'disconnected' | 'error' + /** An open review holds the changes, so the preview lists none. */ + | 'blocked' | 'empty' | 'ready'; @@ -80,7 +82,7 @@ export function usePromotionPreview(projectId: Ref, direction: Ref { expect(getByTestId('promotion-preview-tree')).toBeInTheDocument(); }); + describe('with an open review', () => { + const blocked = () => + billingPreview({ + resources: [], + changes: [], + blocker: { code: 'reviewOpen', reviewId: 'review-1' }, + }); + + async function renderBlocked(canReadReviews: boolean, inboxEnabled = true) { + vi.mocked(api.getPromotionPreview).mockResolvedValue(blocked()); + await router.push('/projects/p1/promotions/promote'); + const pinia = createTestingPinia(); + mockedStore(useRBACStore).hasScope.mockReturnValue(canReadReviews); + const settingsStore = mockedStore(useSettingsStore); + settingsStore.settings = { + ...settingsStore.settings, + inbox: { enabled: inboxEnabled, availableTypes: [], failedTypes: [] }, + }; + const result = renderComponent({ pinia, props: { projectId: 'p1', direction: 'promote' } }); + await waitAllPromises(); + return result; + } + + it('should show the blocker with a link to the Inbox and no changes to promote', async () => { + const { getByTestId, queryByTestId, getByText } = await renderBlocked(true); + + expect(getByTestId('promotion-review-blocker')).toHaveTextContent('A review is open'); + expect(queryByTestId('promotion-preview-tree')).not.toBeInTheDocument(); + expect(queryByTestId('promotion-preview-empty')).not.toBeInTheDocument(); + expect(getByTestId('promotion-preview-submit')).toBeDisabled(); + + await userEvent.click(getByText('Open the Inbox')); + await waitFor(() => expect(router.currentRoute.value.name).toBe(INBOX_VIEW)); + }); + + it('should ask for an admin when the user cannot read reviews', async () => { + const { getByTestId, queryByText } = await renderBlocked(false); + + expect(getByTestId('promotion-review-blocker')).toHaveTextContent( + 'Ask an admin to merge or close the review.', + ); + expect(queryByText('Open the Inbox')).not.toBeInTheDocument(); + }); + + it('should not link to the Inbox when the Inbox is off', async () => { + const { getByTestId, queryByText } = await renderBlocked(true, false); + + expect(getByTestId('promotion-review-blocker')).toHaveTextContent( + 'Ask an admin to merge or close the review.', + ); + expect(queryByText('Open the Inbox')).not.toBeInTheDocument(); + }); + }); + it('should show the error when the refresh of an empty preview fails', async () => { vi.mocked(api.getPromotionPreview).mockResolvedValueOnce(billingPreview({ resources: [] })); const { getByTestId } = await render(); diff --git a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.vue b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.vue index 39faf6cf6ee..fd8c4ab7443 100644 --- a/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.vue +++ b/packages/frontend/editor-ui/src/features/integrations/promotions.ee/views/PromotionPreviewView.vue @@ -28,6 +28,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto import PromotionBlockers from '../components/preview/PromotionBlockers.vue'; import PromotionChangeCards from '../components/preview/PromotionChangeCards.vue'; import PromotionResourceTree from '../components/preview/PromotionResourceTree.vue'; +import PromotionReviewBlocker from '../components/preview/PromotionReviewBlocker.vue'; import { usePromotionOperation } from '../composables/usePromotionOperation'; import { usePromotionResourceDetails } from '../composables/usePromotionResourceDetails'; import { usePromotionPreview } from '../composables/usePromotionPreview'; @@ -365,6 +366,7 @@ const treeResizer = useResizablePanel({ data-test-id="promotion-preview-error" @click:button="refresh" /> + { @@ -1036,7 +1036,7 @@ onMounted(async () => {