feat(core): Let Instance AI read node-type usage from the dependency index (#37539)

This commit is contained in:
Arvin A 2026-09-03 11:23:21 +00:00 committed by GitHub
parent 69190dddde
commit 47c9644392
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1509 additions and 18 deletions

View File

@ -365,6 +365,7 @@ export {
INSTANCE_AI_MCP_CONNECTIONS_FLAG,
INSTANCE_AI_MCP_CONNECTIONS_ENABLED_VARIANT,
CANVAS_NODE_CONTEXT_FLAG,
INSTANCE_AI_NODE_USAGE_FLAG,
domainAccessActionSchema,
domainAccessMetaSchema,
instanceAiApprovalResumeSchema,

View File

@ -2235,6 +2235,10 @@ export const INSTANCE_AI_MCP_CONNECTIONS_ENABLED_VARIANT = 'variant';
/** Enables adding selected canvas nodes as chat context in the AI Assistant */
export const CANVAS_NODE_CONTEXT_FLAG = '104_canvas_aia_node_context';
/** Enables the node-usage context surface for Instance AI: the `node-usage`
* action and the `nodeTypes` filter on `workflows(action="list")`. */
export const INSTANCE_AI_NODE_USAGE_FLAG = '109_instance_ai_node_usage';
/**
* Records a credential field that was rewritten (e.g. routed to the eval wire
* server) during evaluation. Populated for every AI root the server intercepts;

View File

@ -206,6 +206,17 @@ export class InstanceAiConfig {
@Env('N8N_INSTANCE_AI_SETUP_PANEL_ENABLED')
instanceAiSetupPanelEnabled: boolean = false;
/**
* Force-enable the node-usage context surface for Instance AI — the `node-usage` action and
* the `nodeTypes` filter on `workflows(action="list")`.
*
* Operator-level override of the PostHog rollout flag (`109_instance_ai_node_usage`). Cannot
* force-disable: setting this to `false` falls back to PostHog. Gated on its own rather than
* with any other context surface, so a measurement can tell which one moved a result.
*/
@Env('N8N_INSTANCE_AI_NODE_USAGE_ENABLED')
nodeUsageEnabled: boolean = false;
/**
* Activation-capped trial variant for n8n cloud experiment.
* Set by the cloud dashboard at deploy time on one signup-experiment cohort only.

View File

@ -368,6 +368,7 @@ describe('GlobalConfig', () => {
mcpConnectionsEnabled: false,
canvasNodeContextEnabled: false,
instanceAiSetupPanelEnabled: false,
nodeUsageEnabled: false,
activationCapped: false,
activationLockMessageThreshold: 1,
},

View File

@ -115,6 +115,7 @@ export { WorkflowReviewActivityCommentRepository } from './workflow-review-activ
export {
WorkflowDependencyRepository,
WorkflowDependencies,
type NodeUsageScope,
} from './workflow-dependency.repository';
export { WebhookRepository } from './webhook.repository';
export { UserRepository } from './user.repository';

View File

@ -2,10 +2,26 @@ import { DatabaseConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { DataSource, EntityManager, IsNull, LessThan, Repository, Not } from '@n8n/typeorm';
import { WorkflowDependency } from '../entities';
import { SharedWorkflowRepository } from './shared-workflow.repository';
import type { User } from '../entities';
import { WorkflowDependency, WorkflowEntity } from '../entities';
const INDEX_VERSION_ID = 1;
/**
* Which workflows an aggregate over the index may span. The roles come from the caller
* because they are derived from scopes, which live above this layer; everything else about
* readability is decided here by the same subquery the workflow listing uses.
*/
export interface NodeUsageScope {
/** Project roles carrying `workflow:read`, as `RoleService.rolesWithScope` returns them. */
projectRoles: string[];
/** Workflow roles carrying `workflow:read`. */
workflowRoles: string[];
/** Narrow to a single project. Omit to span every project the user can read. */
projectId?: string;
}
/**
* Helper class to collect workflow dependencies before writing them to the database.
*/
@ -40,9 +56,132 @@ export class WorkflowDependencyRepository extends Repository<WorkflowDependency>
constructor(
dataSource: DataSource,
private readonly databaseConfig: DatabaseConfig,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
) {
super(WorkflowDependency, dataSource.manager);
}
/**
* How many workflows in scope use each node type, most-used first.
*
* Answers "what does this project reach for" from the index instead of by fetching every
* workflow and reading its nodes, which is the only other route: the workflow listing filters
* on name alone.
*/
async countNodeTypeUsage(
user: User,
scope: NodeUsageScope,
limit: number,
): Promise<{
workflowsInScope: number;
nodeTypes: Array<{ nodeType: string; workflowCount: number }>;
truncated: boolean;
}> {
const rows = await this.buildScopedDependencyQuery(user, scope)
.andWhere('dependency.dependencyType = :dependencyType', { dependencyType: 'nodeType' })
.select('dependency.dependencyKey', 'nodeType')
// DISTINCT because three HTTP Request nodes in one workflow are one user of the type,
// and because a workflow shared into several projects matches the scope subquery once
// per row it holds there.
.addSelect('COUNT(DISTINCT dependency.workflowId)', 'workflowCount')
.groupBy('dependency.dependencyKey')
.orderBy('COUNT(DISTINCT dependency.workflowId)', 'DESC')
.addOrderBy('dependency.dependencyKey', 'ASC')
// One over the limit, so a cut list is reported as cut rather than guessed at. The
// histogram has to be bounded: an instance-wide read on a large estate would otherwise
// return hundreds of rows, which is the cost this surface exists to avoid.
.limit(limit + 1)
.getRawMany<{ nodeType: string; workflowCount: number | string }>();
// Two queries, not one transaction: the counts and the denominator can disagree by a
// workflow if one is written between them. Reading both here still beats making every
// caller pair them up, and the drift is a row, not a wrong shape.
const workflowsInScope = await this.countWorkflowsInScope(user, scope);
return {
workflowsInScope,
nodeTypes: rows.slice(0, limit).map((row) => ({
nodeType: row.nodeType,
workflowCount: Number(row.workflowCount),
})),
truncated: rows.length > limit,
};
}
/**
* How many workflows the counts are out of. Every indexed workflow carries at least a
* `workflowIndexed` placeholder row, so counting index rows without a type filter counts
* workflows; one not yet indexed is absent from the numerator and the denominator alike.
*/
async countWorkflowsInScope(user: User, scope: NodeUsageScope): Promise<number> {
const total = await this.buildScopedDependencyQuery(user, scope)
.select('COUNT(DISTINCT dependency.workflowId)', 'total')
.getRawOne<{ total: number | string }>();
// Postgres returns COUNT as a bigint string.
return Number(total?.total ?? 0);
}
/**
* Which workflows in scope use a given node type, most recently updated first.
*
* The rung below the histogram: once it says a node type is the house choice, this says which
* workflow to read as the current example.
*/
async findWorkflowsUsingNodeType(
user: User,
scope: NodeUsageScope,
nodeType: string,
limit: number,
): Promise<Array<{ workflowId: string; name: string; updatedAt: Date }>> {
const rows = await this.buildScopedDependencyQuery(user, scope)
.andWhere('dependency.dependencyType = :dependencyType', { dependencyType: 'nodeType' })
.andWhere('dependency.dependencyKey = :nodeType', { nodeType })
.select('dependency.workflowId', 'workflowId')
// Grouped because the same workflow can hold several rows for one node type, so the
// aggregates pick a single name and timestamp per workflow.
.addSelect('MAX(workflow.name)', 'name')
.addSelect('MAX(workflow.updatedAt)', 'updatedAt')
.groupBy('dependency.workflowId')
.orderBy('MAX(workflow.updatedAt)', 'DESC')
.limit(limit)
.getRawMany<{ workflowId: string; name: string; updatedAt: Date | string }>();
return rows.map((row) => ({
workflowId: row.workflowId,
name: row.name,
// SQLite hands back the stored string; Postgres hands back a Date.
updatedAt: row.updatedAt instanceof Date ? row.updatedAt : new Date(row.updatedAt),
}));
}
/**
* Index rows the user may read, scoped by the same subquery the workflow listing is built on
* (`SharedWorkflowRepository.buildSharedWorkflowIdsSubquery`) rather than by a list of ids the
* caller resolved first. Reusing it keeps this on one access path — a second one is the likeliest
* thing here to drift into a leak — and keeps the scope out of the bind parameters, so the query
* does not grow with the estate.
*/
private buildScopedDependencyQuery(user: User, scope: NodeUsageScope) {
const readable = this.sharedWorkflowRepository.buildSharedWorkflowIdsSubquery(user, {
projectRoles: scope.projectRoles,
workflowRoles: scope.workflowRoles,
...(scope.projectId ? { projectId: scope.projectId } : {}),
});
return (
this.createQueryBuilder('dependency')
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = dependency.workflowId')
// Draft rows describe what a workflow currently contains; what someone is building is a
// better statement of preference than what happens to be published.
.where('dependency.publishedVersionId IS NULL')
// Archived workflows are what a project used to do.
.andWhere('workflow.isArchived = :isArchived', { isArchived: false })
.andWhere(`dependency.workflowId IN (${readable.getQuery()})`)
.setParameters(readable.getParameters())
);
}
/**
* Replace the dependencies for a given workflow.
* Uses the workflowVersionId to ensure consistency between the workflow and dependency tables.

View File

@ -668,6 +668,7 @@ export type {
WorkflowTaskService,
WorkflowSummary,
WorkflowDetail,
NodeUsageResult,
WorkflowNode,
WorkflowVersionSummary,
WorkflowVersionDetail,

View File

@ -164,6 +164,14 @@ function getDescription(tool: unknown): string {
return (tool as { description: string }).description;
}
/** Parse through the tool's real input schema, so assertions see what the handler would get —
* zod strips fields the schema does not declare rather than rejecting them. */
function parseInput(tool: unknown, input: unknown): Record<string, unknown> {
const schema = (tool as { inputSchema: { parse: (input: unknown) => Record<string, unknown> } })
.inputSchema;
return schema.parse(input);
}
describe('workflows tool', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -3535,3 +3543,146 @@ describe('workflows tool', () => {
});
});
});
describe('node usage', () => {
const withNodeUsage = (result: unknown = { workflowsInScope: 0, nodeTypes: [] }) => {
const context = createMockContext();
context.workflowService.nodeUsage = vi.fn().mockResolvedValue(result);
return context;
};
describe('capability gate', () => {
// The host attaches `nodeUsage` only when the dependency index is wired behind it, so the
// agent must never be offered an action or a filter that would error.
it('registers the action only when the service exposes nodeUsage', () => {
expect(
getInputSchema(createWorkflowsTool(withNodeUsage(), 'full')).safeParse({
action: 'node-usage',
}).success,
).toBe(true);
expect(
getInputSchema(createWorkflowsTool(createMockContext(), 'full')).safeParse({
action: 'node-usage',
}).success,
).toBe(false);
});
// The field is omitted from the schema rather than ignored downstream, so zod strips it and
// the handler cannot receive it — which is also what keeps it out of the schema the model reads.
it('offers the nodeTypes filter on list under the same condition', () => {
const parseList = (context: InstanceAiContext) =>
parseInput(createWorkflowsTool(context, 'full'), {
action: 'list',
nodeTypes: ['n8n-nodes-base.slack'],
});
expect(parseList(withNodeUsage())).toMatchObject({ nodeTypes: ['n8n-nodes-base.slack'] });
expect(parseList(createMockContext())).not.toHaveProperty('nodeTypes');
});
});
describe('histogram', () => {
it('returns the counts with the denominator and states the surface limits', async () => {
const context = withNodeUsage({
workflowsInScope: 10,
nodeTypes: [{ nodeType: '@n8n/n8n-nodes-langchain.lmChatAnthropic', workflowCount: 10 }],
});
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool<{
workflowsInScope: number;
nodeTypes: Array<{ nodeType: string; workflowCount: number }>;
note: string;
}>(tool, { action: 'node-usage' } as never, {} as never);
expect(result.workflowsInScope).toBe(10);
expect(result.nodeTypes).toEqual([
{ nodeType: '@n8n/n8n-nodes-langchain.lmChatAnthropic', workflowCount: 10 },
]);
// An absence is evidence, not a gap — and the note has to say so, or a type the project
// never uses reads as "unknown" rather than "not chosen".
expect(result.note).toContain('absent from this list is used by no workflow in scope');
expect(result.note).toContain('Node types only');
});
// The claim above is only true of a complete list. On a cut one it is actively wrong, so
// the note has to withdraw it rather than repeat it.
it('withdraws the absence claim when the list is cut', async () => {
const context = withNodeUsage({
workflowsInScope: 40,
nodeTypes: [{ nodeType: 'n8n-nodes-base.slack', workflowCount: 9 }],
truncated: true,
});
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool<{ truncated?: boolean; note: string }>(
tool,
{ action: 'node-usage', limit: 1 } as never,
{} as never,
);
expect(result.truncated).toBe(true);
expect(result.note).toContain('do not read an absence as evidence');
expect(result.note).not.toContain('used by no workflow in scope');
});
it('passes the limit through to the service', async () => {
const context = withNodeUsage();
const tool = createWorkflowsTool(context, 'full');
await executeTool(tool, { action: 'node-usage', limit: 5 } as never, {} as never);
expect(context.workflowService.nodeUsage).toHaveBeenCalledWith({ limit: 5 });
});
});
describe('workflows for a node type', () => {
it('names the workflows using a type and reports truncation', async () => {
const context = withNodeUsage({
workflowsInScope: 12,
workflows: [
{ workflowId: 'wf-1', name: 'Daily sync', updatedAt: '2026-01-01T00:00:00.000Z' },
],
truncated: true,
});
const tool = createWorkflowsTool(context, 'full');
const result = await executeTool(
tool,
{ action: 'node-usage', nodeType: 'n8n-nodes-base.slack', limit: 1 } as never,
{} as never,
);
expect(context.workflowService.nodeUsage).toHaveBeenCalledWith({
nodeType: 'n8n-nodes-base.slack',
limit: 1,
});
expect(result).toEqual({
nodeType: 'n8n-nodes-base.slack',
workflowsInScope: 12,
workflows: [
{ workflowId: 'wf-1', name: 'Daily sync', updatedAt: '2026-01-01T00:00:00.000Z' },
],
truncated: true,
});
});
});
describe('list', () => {
it('passes nodeTypes through to the service', async () => {
const context = withNodeUsage();
const tool = createWorkflowsTool(context, 'full');
await executeTool(
tool,
{ action: 'list', nodeTypes: ['n8n-nodes-base.slack'] } as never,
{} as never,
);
expect(context.workflowService.list).toHaveBeenCalledWith(
expect.objectContaining({ nodeTypes: ['n8n-nodes-base.slack'] }),
);
});
});
});

View File

@ -79,10 +79,19 @@ import { nodeGroupDroppedWarnings } from './workflows/workflow-validation-warnin
// ── Action schemas ──────────────────────────────────────────────────────────
// `list` and `setup` share this field, and the schema sanitizer requires one
// description per shared field, so it has to cover both uses.
// `list`, `node-usage` and `setup` share these fields, and the schema sanitizer rejects
// conflicting descriptions for one field name across the union, so each has to read correctly
// for every action that uses it.
const PROJECT_ID_FIELD_DESCRIPTION =
'Project ID, obtainable from `workspace(action="list-projects")`. For `list`: read that one project instead of the default scope — use it for "what is in project X" rather than listing the whole instance and guessing which results belong to X. Read-only, so it narrows what you can already see rather than widening it, and it does not move where you can write. For `setup`: scope credential creation to that project.';
'Project ID, obtainable from `workspace(action="list-projects")`. For `list` and `node-usage`: read that one project instead of the default scope — use it for "what is in project X" rather than reading the whole instance and guessing which results belong to X. Read-only, so it narrows what you can already see rather than widening it, and it does not move where you can write. For `setup`: scope credential creation to that project.';
const SCOPE_FIELD_DESCRIPTION =
"Which project(s) to read. Defaults to this conversation's project. Use 'instance' only when you have a clear reason to look across all projects you can access.";
const LIMIT_FIELD_DESCRIPTION = 'Max results to return';
const NODE_TYPES_FIELD_DESCRIPTION =
'Full node types, e.g. ["n8n-nodes-base.slack"]. Matched against the nodes a workflow actually contains, from an index — so it finds users of a node however the workflow is named, and costs one call however many workflows exist. Keeps only workflows containing at least one of these.';
const listAction = z.object({
action: z
@ -96,19 +105,51 @@ const listAction = z.object({
.describe(
'Substring filter on the workflow NAME only — it does not match node types, descriptions, or what a workflow does. Omit it whenever you need the actual inventory (what exists here, project status, what to do next): a name-filtered list is not the set of workflows in scope. Use it only when the user named a workflow, or to locate one you already know exists.',
),
limit: z.number().int().positive().max(100).optional().describe('Max results to return'),
nodeTypes: z.array(z.string()).optional().describe(NODE_TYPES_FIELD_DESCRIPTION),
limit: z.number().int().positive().max(100).optional().describe(LIMIT_FIELD_DESCRIPTION),
status: z
.enum(['active', 'archived', 'all'])
.optional()
.describe(
'Which workflows to list. Defaults to active; use archived to find workflows that can be restored.',
),
scope: z
.enum(['project', 'instance'])
scope: z.enum(['project', 'instance']).optional().describe(SCOPE_FIELD_DESCRIPTION),
projectId: z.string().optional().describe(PROJECT_ID_FIELD_DESCRIPTION),
});
/** `list` as it looks without the dependency index behind it — the shape the agent saw before
* node usage existed. Registered instead of `listAction` when the capability is off, so the
* off arm really lacks the field rather than being told to avoid it. `Input` still derives
* from the full `listAction`, so the handler keeps its types either way. */
const listActionWithoutNodeTypes = listAction.omit({ nodeTypes: true });
/**
* The cheap rung of preference discovery. `list` filters on the workflow name only, so learning
* what a project is built out of otherwise means fetching every workflow and reading its nodes:
* measured across ten workflows, that is ~6,500 tokens against ~85 for this aggregate.
*/
const nodeUsageAction = z.object({
action: z
.literal('node-usage')
.describe(
'Which node types the workflows in scope actually use, and how many use each, most-used ' +
'first. Read this BEFORE opening workflows when the question is what a project is built ' +
'out of — its conventions, which integrations are in play, whether something already ' +
'exists. Call it with no `nodeType` for the overview; call it with one to get the ' +
'workflows using that type, most recently updated first, when you want to read a ' +
'current example.',
),
nodeType: z
.string()
.optional()
.describe(
"Which project(s) to search. Defaults to this conversation's project. Use 'instance' only when you have a clear reason to look across all projects you can access.",
'A single full node type, e.g. "@n8n/n8n-nodes-langchain.lmChatAnthropic". Omit it for the ' +
'overview of every type in use.',
),
// Caps both shapes: node types in the overview, workflows when a `nodeType` is given. The
// ceiling is above the overview's default so raising it after a truncated answer works.
limit: z.number().int().positive().max(200).optional().describe(LIMIT_FIELD_DESCRIPTION),
scope: z.enum(['project', 'instance']).optional().describe(SCOPE_FIELD_DESCRIPTION),
projectId: z.string().optional().describe(PROJECT_ID_FIELD_DESCRIPTION),
});
@ -323,6 +364,7 @@ interface WorkflowToolContext {
// regardless of which dynamic subset the schema actually includes.
type Input =
| z.infer<typeof listAction>
| z.infer<typeof nodeUsageAction>
| z.infer<typeof getAction>
| z.infer<typeof getJsonAction>
| z.infer<typeof getAsCodeAction>
@ -344,6 +386,7 @@ type PublishRollbackResult = {
};
export type WorkflowAction =
| 'list'
| 'node-usage'
| 'get'
| 'get-json'
| 'get-as-code'
@ -371,6 +414,9 @@ type WorkflowsToolOptionsInput = WorkflowsToolOptions | 'full' | 'orchestrator';
const WORKFLOW_ACTION_ORDER = [
'list',
// Directly after `list`: it answers the same discovery question far more cheaply, and this
// ordering is what the agent reads first in the tool schema.
'node-usage',
'get',
'get-json',
'get-as-code',
@ -388,6 +434,7 @@ const WORKFLOW_ACTION_ORDER = [
const WORKFLOW_ACTION_LABELS = {
list: 'list',
'node-usage': 'summarize which node types are in use',
get: 'inspect',
'get-json': 'inspect full WorkflowJSON',
'get-as-code': 'convert existing workflows to TypeScript SDK code',
@ -413,9 +460,14 @@ function getSupportedWorkflowActionSchemas(
): Partial<Record<WorkflowAction, WorkflowActionSchema>> {
const hasNamedVersions = !!context.workflowService.updateVersion;
const hasVersions = !!context.workflowService.listVersions;
// One gate for both halves of the surface: the host attaches `nodeUsage` only when the
// dependency index is wired and the capability is on, so the agent is never offered an
// action or a filter it would get an error from.
const hasNodeUsage = !!context.workflowService.nodeUsage;
return {
list: listAction,
list: hasNodeUsage ? listAction : listActionWithoutNodeTypes,
...(hasNodeUsage ? { 'node-usage': nodeUsageAction } : {}),
get: getAction,
...(surface !== 'orchestrator' ? { 'get-json': getJsonAction } : {}),
'get-as-code': getAsCodeAction,
@ -486,6 +538,57 @@ async function resolveWorkflowName(
.catch(() => workflowId);
}
/**
* Renders the counts against the scope total, because "10 of 10" is the statement a preference is
* made of and a bare count says nothing without its denominator.
*/
async function handleNodeUsage(
context: InstanceAiContext,
input: Extract<Input, { action: 'node-usage' }>,
) {
if (!context.workflowService.nodeUsage) {
return {
note: 'Node usage is not available on this instance. Read the workflows you need directly.',
};
}
const result = await context.workflowService.nodeUsage({
...(input.nodeType ? { nodeType: input.nodeType } : {}),
...(input.limit !== undefined ? { limit: input.limit } : {}),
...(input.scope ? { scope: input.scope } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
});
if (input.nodeType) {
return {
nodeType: input.nodeType,
workflowsInScope: result.workflowsInScope,
workflows: result.workflows ?? [],
...(result.truncated ? { truncated: true } : {}),
};
}
// What an absence means depends on whether the list is complete. On a full list, a missing
// type is a choice the user has not made, and saying so is most of the value. On a cut list
// it means nothing at all, and the note must withdraw the claim rather than repeat it.
const absence = result.truncated
? 'This list is CUT at the top ' +
`${result.nodeTypes?.length ?? 0} most-used types — a type missing from it may still be ` +
'in use, so do not read an absence as evidence. Raise `limit`, or narrow with `projectId`.'
: 'A type absent from this list is used by no workflow in scope.';
return {
workflowsInScope: result.workflowsInScope,
nodeTypes: result.nodeTypes ?? [],
...(result.truncated ? { truncated: true } : {}),
// The limit of the surface is named so counts are never quoted as parameter-level house style.
note:
`Counts are how many workflows use each type, out of workflowsInScope. ${absence} ` +
'Node types only — for parameter-level convention (retry settings, naming, model ' +
'options), read one workflow with `get`.',
};
}
async function handleList(context: InstanceAiContext, input: Extract<Input, { action: 'list' }>) {
const { workflows, total, totalInScope } = await context.workflowService.list({
limit: input.limit,
@ -493,6 +596,7 @@ async function handleList(context: InstanceAiContext, input: Extract<Input, { ac
...(input.status ? { status: input.status } : {}),
...(input.scope ? { scope: input.scope } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.nodeTypes?.length ? { nodeTypes: input.nodeTypes } : {}),
});
// A partial list must never read as the complete inventory: guessed name
@ -2014,6 +2118,8 @@ export function createWorkflowsTool(
switch (workflowInput.action) {
case 'list':
return await handleList(context, workflowInput);
case 'node-usage':
return await handleNodeUsage(context, workflowInput);
case 'get':
return await handleGet(context, workflowInput);
case 'get-json':

View File

@ -310,6 +310,22 @@ export interface WorkflowListResult {
totalInScope: number;
}
/**
* What the workflows in scope are built out of. The cheap rung of preference discovery:
* `nodeTypes` answers "what does this project reach for" without opening a workflow, and
* `workflows` names the ones using a given type so one read gets the current house style.
*/
export interface NodeUsageResult {
/** Indexed, non-archived workflows in scope — the denominator for every count. */
workflowsInScope: number;
/** Set when no `nodeType` was asked for: every node type in use, most-used first. */
nodeTypes?: Array<{ nodeType: string; workflowCount: number }>;
/** Set when a `nodeType` was asked for: the workflows using it, most recently updated first. */
workflows?: Array<{ workflowId: string; name: string; updatedAt: string }>;
/** True when the limit cut the list short, so a partial answer is never read as the whole. */
truncated?: boolean;
}
export interface InstanceAiWorkflowService {
list(options?: {
query?: string;
@ -323,7 +339,27 @@ export interface InstanceAiWorkflowService {
* access. Writes stay locked to the thread's bound project regardless.
*/
projectId?: string;
/**
* Keep only workflows containing at least one node of these types
* (`n8n-nodes-base.slack`). Resolved from the dependency index, so it matches what a
* workflow actually contains rather than what its name suggests, and costs one join
* instead of a fetch per workflow.
*/
nodeTypes?: string[];
}): Promise<WorkflowListResult>;
/**
* Node-type usage across the workflows in scope, read from the dependency index rather than by
* fetching workflows. Without `nodeType` it returns the histogram; with one, the workflows using
* it. Node types only — parameter-level house style still needs a `get`.
*
* Optional: present only where the host wires the index behind it.
*/
nodeUsage?(options?: {
nodeType?: string;
limit?: number;
scope?: 'project' | 'instance';
projectId?: string;
}): Promise<NodeUsageResult>;
get(workflowId: string): Promise<WorkflowDetail>;
/** Get the workflow as the SDK's WorkflowJSON (full node data for generateWorkflowCode).
* Pass a versionId to get a past version's graph instead of the current draft. */

View File

@ -0,0 +1,341 @@
// The barrel is mocked so this file exercises the adapter's own wiring rather than pulling the
// whole instance-ai package in behind it, matching the other adapter tests.
vi.mock('@n8n/instance-ai', async () => {
const { WorkflowSaveConflictError } = await import(
'../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error.js'
);
const { WorkflowNotFoundError } = await import(
'../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error.js'
);
const { WorkflowEditorLockedError } = await import(
'../../../../../@n8n/instance-ai/src/errors/workflow-editor-locked.error.js'
);
return {
WorkflowSaveConflictError,
WorkflowNotFoundError,
WorkflowEditorLockedError,
wrapUntrustedData: (content: string) => content,
builderTemplatesOptionsFromEnv: () => ({}),
deriveCredentialHosts: vi.fn().mockReturnValue([]),
BuilderTemplatesService: class {
async getBundle() {
return { files: [], indexTxt: '', version: null };
}
getVersion() {
return null;
}
},
};
});
vi.mock('@n8n/ai-utilities', () => ({
braveSearch: vi.fn(),
searxngSearch: vi.fn(),
}));
import type { Logger } from '@n8n/backend-common';
import { INSTANCE_AI_NODE_USAGE_FLAG } from '@n8n/api-types';
import type { OutboundHttp } from '@n8n/backend-network';
import type { GlobalConfig } from '@n8n/config';
import type {
AiBuilderTemporaryWorkflowRepository,
ExecutionRepository,
ProjectRepository,
SharedWorkflowRepository,
User,
WorkflowRepository,
} from '@n8n/db';
import { GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import type { InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { ActiveExecutions } from '@/active-executions';
import type { CollaborationService } from '@/collaboration/collaboration.service';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { EventService } from '@/events/event.service';
import type { ExecutionPersistence } from '@/executions/execution-persistence';
import type { License } from '@/license';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { DataTableRepository } from '@/modules/data-table/data-table.repository';
import type { DataTableService } from '@/modules/data-table/data-table.service';
import type { WorkflowDependencyQueryService } from '@/modules/workflow-index/workflow-dependency-query.service';
import type { NodeTypes } from '@/node-types';
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { PostHogClient } from '@/posthog';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import type { FolderService } from '@/services/folder.service';
import type { InstanceWriteAccessService } from '@/services/instance-write-access.service';
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
import type { ProjectService } from '@/services/project.service.ee';
import type { RoleService } from '@/services/role.service';
import type { TagService } from '@/services/tag.service';
import type { Telemetry } from '@/telemetry';
import type { WorkflowRunner } from '@/workflow-runner';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import type { WorkflowService } from '@/workflows/workflow.service';
import type { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
import type { InstanceAiSettingsService } from '../instance-ai-settings.service';
import { InstanceAiAdapterService } from '../instance-ai.adapter.service';
import type { WorkflowTemplatesService } from '../workflow-templates.service';
const user = mock<User>({ id: 'user-1', role: GLOBAL_MEMBER_ROLE });
const workflowService = mock<WorkflowService>();
function buildAdapter(options: { dependencyQueryService?: WorkflowDependencyQueryService }) {
const logger = mock<Logger>();
const globalConfig = mock<GlobalConfig>({ ai: { allowSendingParameterValues: true } });
return new InstanceAiAdapterService(
logger,
globalConfig,
workflowService,
mock<WorkflowFinderService>(),
mock<WorkflowRepository>(),
mock<SharedWorkflowRepository>(),
mock<ProjectRepository>(),
mock<ExecutionRepository>(),
mock<CredentialsService>(),
mock<CredentialsFinderService>(),
mock<ActiveExecutions>(),
mock<WorkflowRunner>(),
mock<LoadNodesAndCredentials>(),
mock<NodeTypes>(),
mock<InstanceSettings>({ staticCacheDir: '/tmp/test-cache', n8nFolder: '/tmp/test-cache' }),
mock<DataTableService>(),
mock<DataTableRepository>(),
new NodeResourceExplorerService(
logger,
mock<DynamicNodeParametersService>(),
mock<CredentialsFinderService>(),
mock<ProjectRepository>(),
mock<NodeTypes>(),
),
mock<FolderService>(),
mock<ProjectService>(),
mock<TagService>(),
mock<InstanceWriteAccessService>(),
mock<InstanceAiSettingsService>(),
mock<WorkflowHistoryService>(),
mock<EnterpriseWorkflowService>(),
mock<License>(),
mock<ExecutionPersistence>(),
mock<EventService>(),
mock<RoleService>(),
mock<Telemetry>(),
mock<AiBuilderTemporaryWorkflowRepository>(),
mock<OutboundHttp>(),
mock<AiGatewayService>(),
mock<WorkflowTemplatesService>(),
mock<CollaborationService>(),
mock<PolicyEnforcementService>(),
undefined,
undefined,
undefined,
options.dependencyQueryService,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(Container, 'get').mockReturnValue(mock<ExecutionPersistence>());
});
describe('InstanceAiAdapterService node usage', () => {
describe('isNodeUsageEnabled()', () => {
const gateFor = async (flags: Record<string, unknown> | Error) => {
const postHogClient = mock<PostHogClient>();
if (flags instanceof Error) {
postHogClient.getFeatureFlags.mockRejectedValue(flags);
} else {
postHogClient.getFeatureFlags.mockResolvedValue(flags as never);
}
vi.spyOn(Container, 'get').mockImplementation(((token: unknown) =>
token === PostHogClient ? postHogClient : mock<ExecutionPersistence>()) as never);
return await buildAdapter({}).isNodeUsageEnabled(user);
};
it('is on for a user in the rollout', async () => {
expect(await gateFor({ [INSTANCE_AI_NODE_USAGE_FLAG]: true })).toBe(true);
});
it('is off when the flag is absent', async () => {
expect(await gateFor({})).toBe(false);
});
it('is off when the flag is explicitly false', async () => {
expect(await gateFor({ [INSTANCE_AI_NODE_USAGE_FLAG]: false })).toBe(false);
});
// A flag-plane outage must not switch a context surface on by accident.
it('fails closed when PostHog is unreachable', async () => {
expect(await gateFor(new Error('PostHog unreachable'))).toBe(false);
});
});
describe('capability gate', () => {
// The tool registers the action from the method's presence, so absence here is what actually
// removes the surface from the agent — not a check further down.
// Default-off is the point of the rollout gate: a context created without an explicit
// decision must not carry the surface.
it('omits nodeUsage when the caller passes no gate decision', () => {
const service = buildAdapter({
dependencyQueryService: mock<WorkflowDependencyQueryService>(),
});
expect(service.createContext(user).workflowService.nodeUsage).toBeUndefined();
});
it('omits nodeUsage when the rollout gate is closed', () => {
const service = buildAdapter({
dependencyQueryService: mock<WorkflowDependencyQueryService>(),
});
const context = service.createContext(user, { nodeUsageEnabled: false });
expect(context.workflowService.nodeUsage).toBeUndefined();
});
it('omits nodeUsage when the dependency index is not wired', () => {
const service = buildAdapter({});
expect(
service.createContext(user, { nodeUsageEnabled: true }).workflowService.nodeUsage,
).toBeUndefined();
});
it('exposes nodeUsage when the gate is open and the index is wired', () => {
const service = buildAdapter({
dependencyQueryService: mock<WorkflowDependencyQueryService>(),
});
expect(
service.createContext(user, { nodeUsageEnabled: true }).workflowService.nodeUsage,
).toBeDefined();
});
});
describe('nodeUsage()', () => {
it("defaults to the thread's bound project", async () => {
const dependencyQueryService = mock<WorkflowDependencyQueryService>();
dependencyQueryService.getNodeTypeUsage.mockResolvedValue({
workflowsInScope: 3,
nodeTypes: [{ nodeType: 'n8n-nodes-base.slack', workflowCount: 2 }],
});
const service = buildAdapter({ dependencyQueryService });
const context = service.createContext(user, {
projectId: 'bound-project',
nodeUsageEnabled: true,
});
const result = await context.workflowService.nodeUsage?.();
expect(dependencyQueryService.getNodeTypeUsage).toHaveBeenCalledWith(user, {
projectId: 'bound-project',
});
expect(result).toEqual({
workflowsInScope: 3,
nodeTypes: [{ nodeType: 'n8n-nodes-base.slack', workflowCount: 2 }],
});
});
it('drops the project filter when the caller widens to the instance', async () => {
const dependencyQueryService = mock<WorkflowDependencyQueryService>();
dependencyQueryService.getNodeTypeUsage.mockResolvedValue({
workflowsInScope: 0,
nodeTypes: [],
});
const service = buildAdapter({ dependencyQueryService });
const context = service.createContext(user, {
projectId: 'bound-project',
nodeUsageEnabled: true,
});
await context.workflowService.nodeUsage?.({ scope: 'instance' });
expect(dependencyQueryService.getNodeTypeUsage).toHaveBeenCalledWith(user, {});
});
it('serialises workflow timestamps for the agent', async () => {
const dependencyQueryService = mock<WorkflowDependencyQueryService>();
dependencyQueryService.getNodeTypeUsage.mockResolvedValue({
workflowsInScope: 5,
workflows: [
{
workflowId: 'wf-1',
name: 'Daily sync',
updatedAt: new Date('2026-01-02T03:04:05.000Z'),
},
],
truncated: true,
});
const service = buildAdapter({ dependencyQueryService });
const context = service.createContext(user, { nodeUsageEnabled: true });
const result = await context.workflowService.nodeUsage?.({
nodeType: 'n8n-nodes-base.slack',
});
expect(result).toEqual({
workflowsInScope: 5,
workflows: [
{ workflowId: 'wf-1', name: 'Daily sync', updatedAt: '2026-01-02T03:04:05.000Z' },
],
truncated: true,
});
});
});
describe('list()', () => {
it('passes nodeTypes into the scope filter so totalInScope describes the same set', async () => {
workflowService.getMany.mockResolvedValue({ workflows: [], count: 0 });
const service = buildAdapter({
dependencyQueryService: mock<WorkflowDependencyQueryService>(),
});
const context = service.createContext(user, {
projectId: 'bound-project',
nodeUsageEnabled: true,
});
await context.workflowService.list({ nodeTypes: ['n8n-nodes-base.slack'], query: 'sync' });
// Both reads carry the node-type filter: the second exists to say how many the *name*
// filter hid, which would misreport if the two scopes differed.
expect(workflowService.getMany).toHaveBeenNthCalledWith(1, user, {
take: 50,
filter: {
isArchived: false,
projectId: 'bound-project',
nodeTypes: ['n8n-nodes-base.slack'],
query: 'sync',
},
});
expect(workflowService.getMany).toHaveBeenNthCalledWith(2, user, {
take: 1,
filter: {
isArchived: false,
projectId: 'bound-project',
nodeTypes: ['n8n-nodes-base.slack'],
},
});
});
it('ignores nodeTypes when the capability is off', async () => {
workflowService.getMany.mockResolvedValue({ workflows: [], count: 0 });
const service = buildAdapter({});
const context = service.createContext(user, { projectId: 'bound-project' });
await context.workflowService.list({ nodeTypes: ['n8n-nodes-base.slack'] });
expect(workflowService.getMany).toHaveBeenCalledWith(user, {
take: 50,
filter: { isArchived: false, projectId: 'bound-project' },
});
});
});
});

View File

@ -102,6 +102,9 @@ function globalConfigStub(
return {
ai: { allowSendingParameterValues: overrides.allowSendingParameterValues ?? false },
executions: { mode: overrides.queueMode ? 'queue' : 'regular' },
// Node usage is gated on the dependency index being wired too, which these tests do not
// pass, so the value here only has to exist. See instance-ai.adapter.node-usage.test.ts.
instanceAi: { nodeUsageEnabled: false },
} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[1];
}

View File

@ -867,6 +867,7 @@ describe('InstanceAiService — runtime workspace setup', () => {
getNodeDefinitionDirs: Mock;
isConfigEvalsEnabled: Mock;
isMcpConnectionsEnabled: Mock;
isNodeUsageEnabled: Mock;
};
instanceWriteAccess: { isReadOnly: Mock };
modelService: { resolveAgentModelConfig: Mock; resolveProxyModel: Mock };
@ -914,6 +915,7 @@ describe('InstanceAiService — runtime workspace setup', () => {
getNodeDefinitionDirs: vi.fn(() => []),
isConfigEvalsEnabled: vi.fn().mockResolvedValue(true),
isMcpConnectionsEnabled: vi.fn().mockResolvedValue(false),
isNodeUsageEnabled: vi.fn().mockResolvedValue(false),
};
service.instanceWriteAccess = { isReadOnly: vi.fn(() => false) };
service.modelService = {

View File

@ -4,6 +4,7 @@ import {
CONFIG_EVALUATIONS_FLAG,
CONFIG_EVALUATIONS_ENABLED_VARIANT,
INSTANCE_AI_MCP_CONNECTIONS_FLAG,
INSTANCE_AI_NODE_USAGE_FLAG,
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
upsertEvaluationConfigSchema,
INSTANCE_AI_MCP_CONNECTIONS_ENABLED_VARIANT,
@ -38,6 +39,7 @@ import type {
DataTableSummary,
DataTableColumnInfo,
WorkflowSummary,
NodeUsageResult,
WorkflowDetail,
WorkflowNode,
WorkflowVersionSummary,
@ -139,6 +141,7 @@ import { DataTableService } from '@/modules/data-table/data-table.service';
import { MCP_REGISTRY_PACKAGE_NAME } from '@/modules/mcp-registry/node-description-transform';
import type { McpRegistrySearchResult } from '@/modules/mcp-registry/registry/mcp-registry-search';
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
import { WorkflowDependencyQueryService } from '@/modules/workflow-index/workflow-dependency-query.service';
import { NodeCatalogService } from '@/node-catalog';
import { NodeTypes } from '@/node-types';
import { userHasScopes } from '@/permissions.ee/check-access';
@ -337,6 +340,9 @@ export class InstanceAiAdapterService {
// DI (by type, not position) always provides it in a running instance.
private readonly evaluationConfigService?: EvaluationConfigService,
private readonly llmJudgeProviderRegistry?: LlmJudgeProviderRegistry,
// Appended rather than grouped with the other query services: existing tests construct this
// service positionally, so inserting mid-list renames every later argument.
private readonly workflowDependencyQueryService?: WorkflowDependencyQueryService,
) {
this.logger = logger.scoped('instance-ai');
this.allowSendingParameterValues = globalConfig.ai.allowSendingParameterValues;
@ -367,6 +373,9 @@ export class InstanceAiAdapterService {
/** Per-user MCP registry gate (via `isMcpConnectionsEnabled`). Falsy →
* mcp service/tool not wired. */
mcpConnectionsEnabled?: boolean;
/** Per-user node-usage gate (via `isNodeUsageEnabled`). Falsy → neither the
* `node-usage` action nor the `nodeTypes` filter on `list` is offered. */
nodeUsageEnabled?: boolean;
/** Host-resolved model for the run — fallback for utility LLM calls
* (simulation fixtures, destructiveness classification). */
modelId?: ModelConfig;
@ -382,6 +391,7 @@ export class InstanceAiAdapterService {
agentId,
configEvalsEnabled,
mcpConnectionsEnabled,
nodeUsageEnabled,
modelId,
} = options ?? {};
@ -400,7 +410,7 @@ export class InstanceAiAdapterService {
userId: user.id,
projectId,
modelId,
workflowService: this.createWorkflowAdapter(user, threadId, projectId),
workflowService: this.createWorkflowAdapter(user, threadId, projectId, nodeUsageEnabled),
executionService: this.createExecutionAdapter(user, pushRef, threadId),
credentialService,
nodeService: this.createNodeAdapter(user),
@ -485,6 +495,21 @@ export class InstanceAiAdapterService {
return flags?.[CONFIG_EVALUATIONS_FLAG] === CONFIG_EVALUATIONS_ENABLED_VARIANT;
}
/**
* Gate for the node-usage context surface. PostHog owns cohort rollout;
* `N8N_INSTANCE_AI_NODE_USAGE_ENABLED` force-enables because {@link PostHogClient}
* layers that override on top of the resolved flags. Fails closed on any PostHog
* error, so an outage never switches a context surface on by accident.
*/
async isNodeUsageEnabled(user: User): Promise<boolean> {
try {
const flags = await Container.get(PostHogClient).getFeatureFlags(user);
return flags?.[INSTANCE_AI_NODE_USAGE_FLAG] === true;
} catch {
return false;
}
}
/** Gate for MCP registry discovery tool. All three must hold:
* 1. the `mcp-registry` module is active
* 2. the admin allows MCP access instance-wide
@ -649,6 +674,7 @@ export class InstanceAiAdapterService {
user: User,
threadId?: string,
boundProjectId?: string,
nodeUsageGateOpen = false,
): InstanceAiWorkflowService {
const {
workflowService,
@ -665,9 +691,23 @@ export class InstanceAiAdapterService {
telemetry,
collaborationService,
policyEnforcementService,
workflowDependencyQueryService,
} = this;
const logger = this.logger;
const assertNotReadOnly = () => this.assertInstanceNotReadOnly('workflows');
// Resolved once per context, upstream in `createContext`: the tool registers the action from
// the method's presence, so nothing downstream has to know a rollout flag exists.
const nodeUsageEnabled = nodeUsageGateOpen && workflowDependencyQueryService !== undefined;
/**
* Which project a read targets. An explicit `projectId` wins, otherwise the thread's own
* project unless the caller widened to the whole instance. Shared by `list` and `nodeUsage`
* so the two never disagree about what "this project" means.
*/
const resolveTargetProjectId = (options?: {
projectId?: string;
scope?: 'project' | 'instance';
}) => options?.projectId ?? (options?.scope !== 'instance' ? boundProjectId : undefined);
const { resolveBoundProjectId } = this.createProjectScopeHelpers(user, boundProjectId);
const redactParameters = !allowSendingParameterValues;
@ -700,18 +740,49 @@ export class InstanceAiAdapterService {
};
return {
// Present only when the index is wired and the flag is on; the tool reads that presence
// to decide whether the action exists at all.
...(nodeUsageEnabled && workflowDependencyQueryService
? {
async nodeUsage(options): Promise<NodeUsageResult> {
const targetProjectId = resolveTargetProjectId(options);
const result = await workflowDependencyQueryService.getNodeTypeUsage(user, {
...(targetProjectId ? { projectId: targetProjectId } : {}),
...(options?.nodeType ? { nodeType: options.nodeType } : {}),
...(options?.limit !== undefined ? { limit: options.limit } : {}),
});
return {
workflowsInScope: result.workflowsInScope,
...(result.nodeTypes ? { nodeTypes: result.nodeTypes } : {}),
...(result.workflows
? {
workflows: result.workflows.map((workflow) => ({
...workflow,
updatedAt: workflow.updatedAt.toISOString(),
})),
}
: {}),
...(result.truncated ? { truncated: true } : {}),
};
},
}
: {}),
async list(options) {
// An explicit projectId targets one project; otherwise the thread's own
// project unless the caller widened to the whole instance. Either way it
// goes in as a *filter* on `getMany`, which resolves readability from the
// user's own project/workflow roles — so this can only narrow the set the
// The target project goes in as a *filter* on `getMany`, which resolves readability
// from the user's own project/workflow roles — so this can only narrow the set the
// caller could already read, never widen it. Writes keep using
// `resolveBoundProjectId` and stay locked to the bound project.
const targetProjectId =
options?.projectId ?? (options?.scope !== 'instance' ? boundProjectId : undefined);
const targetProjectId = resolveTargetProjectId(options);
const scopeFilter = {
...(options?.status === 'all' ? {} : { isArchived: options?.status === 'archived' }),
...(targetProjectId ? { projectId: targetProjectId } : {}),
// Part of the scope, not of the name filter: `totalInScope` has to describe the
// same node-type-filtered set, or the "hidden workflows" note misreports.
...(nodeUsageEnabled && options?.nodeTypes?.length
? { nodeTypes: options.nodeTypes }
: {}),
};
const filter = {
...scopeFilter,

View File

@ -2362,6 +2362,7 @@ export class InstanceAiService {
const configEvalsEnabled = await this.adapterService.isConfigEvalsEnabled(user);
const mcpConnectionsEnabled = await this.adapterService.isMcpConnectionsEnabled(user);
const nodeUsageEnabled = await this.adapterService.isNodeUsageEnabled(user);
const context = this.adapterService.createContext(user, {
searchProxyConfig,
pushRef,
@ -2372,6 +2373,7 @@ export class InstanceAiService {
this.evalCredentialAllowlists.shouldBypassTest(threadId, credentialId),
configEvalsEnabled,
mcpConnectionsEnabled,
nodeUsageEnabled,
modelId,
});

View File

@ -23,6 +23,32 @@ import { DataTableRepository } from '@/modules/data-table/data-table.repository'
import { RoleService } from '@/services/role.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
/** Workflows named for a node type when the caller does not say how many it wants. */
const DEFAULT_NODE_USAGE_WORKFLOW_LIMIT = 10;
/** Node types in the histogram when the caller does not say how many it wants. Higher than the
* workflow limit because the histogram is the answer rather than a sample of it, and a project
* rarely reaches for more types than this — but it stays bounded, because an instance-wide read
* otherwise returns every type in use. */
const DEFAULT_NODE_USAGE_TYPE_LIMIT = 100;
/**
* Node-type usage over the workflows in scope. Exactly one of `nodeTypes` and `workflows` is set:
* the histogram when no node type was named, the workflows using it when one was.
*/
export interface NodeTypeUsage {
/** Indexed, non-archived workflows in scope — the denominator for every count. */
workflowsInScope: number;
nodeTypes?: Array<{ nodeType: string; workflowCount: number }>;
workflows?: Array<{ workflowId: string; name: string; updatedAt: Date }>;
/**
* Whether the limit cut the list short. On the histogram this also decides whether an absent
* node type means "not used" or "not shown", so callers must not report absence as evidence
* when it is true.
*/
truncated?: boolean;
}
interface RawDepMaps {
agentUsageMap: Map<string, Set<string>>;
credMap: Map<string, Set<string>>;
@ -53,6 +79,56 @@ export class WorkflowDependencyQueryService {
private readonly moduleRegistry: ModuleRegistry,
) {}
/**
* Node-type usage across the workflows a user can read, read from the dependency index rather
* than by fetching workflows.
*
* Without `nodeType` it returns the histogram; with one, the workflows using it. Either way the
* denominator ships with the answer, because a count means nothing without it.
*/
async getNodeTypeUsage(
user: User,
options: { projectId?: string; nodeType?: string; limit?: number } = {},
): Promise<NodeTypeUsage> {
// The same scopes-to-roles step the workflow listing performs, so this reads exactly the
// workflows a listing would return rather than applying a rule of its own.
const [projectRoles, workflowRoles] = await Promise.all([
this.roleService.rolesWithScope('project', ['workflow:read']),
this.roleService.rolesWithScope('workflow', ['workflow:read']),
]);
const scope = {
projectRoles,
workflowRoles,
...(options.projectId ? { projectId: options.projectId } : {}),
};
if (!options.nodeType) {
return await this.dependencyRepository.countNodeTypeUsage(
user,
scope,
options.limit ?? DEFAULT_NODE_USAGE_TYPE_LIMIT,
);
}
const limit = options.limit ?? DEFAULT_NODE_USAGE_WORKFLOW_LIMIT;
const [rows, workflowsInScope] = await Promise.all([
// One over the limit, so a truncated list is reported as truncated rather than guessed at.
this.dependencyRepository.findWorkflowsUsingNodeType(
user,
scope,
options.nodeType,
limit + 1,
),
this.dependencyRepository.countWorkflowsInScope(user, scope),
]);
return {
workflowsInScope,
workflows: rows.slice(0, limit),
...(rows.length > limit ? { truncated: true } : {}),
};
}
async getDependencyCounts(
resourceIds: string[],
resourceType: DependencyResourceType,

View File

@ -1,6 +1,7 @@
import {
AGENT_EVALS_FLAG,
CANVAS_NODE_CONTEXT_FLAG,
INSTANCE_AI_NODE_USAGE_FLAG,
CONFIG_EVALUATIONS_ENABLED_VARIANT,
CONFIG_EVALUATIONS_FLAG,
EVAL_COLLECTIONS_FLAG,
@ -227,6 +228,10 @@ export class PostHogClient {
overrides[CANVAS_NODE_CONTEXT_FLAG] = true;
}
if (this.globalConfig.instanceAi.nodeUsageEnabled) {
overrides[INSTANCE_AI_NODE_USAGE_FLAG] = true;
}
return Object.keys(overrides).length === 0 ? flags : { ...flags, ...overrides };
}
}

View File

@ -1,7 +1,20 @@
import { testDb, createWorkflow } from '@n8n/backend-test-utils';
import { WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db';
import {
testDb,
createWorkflow,
createTeamProject,
linkUserToProject,
} from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db';
import {
SharedWorkflowRepository,
WorkflowDependencyRepository,
WorkflowDependencies,
WorkflowRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { createMember, createOwner } from '../../shared/db/users';
describe('WorkflowDependencyRepository', () => {
let workflowDependencyRepository: WorkflowDependencyRepository;
@ -262,3 +275,337 @@ describe('WorkflowDependencyRepository', () => {
});
});
});
const ANTHROPIC = '@n8n/n8n-nodes-langchain.lmChatAnthropic';
const OPENAI = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
const SLACK = 'n8n-nodes-base.slack';
/**
* These back a context surface the agent reads to learn what a project is built out of, so the
* thing worth asserting is the scoping: an aggregate that quietly spans projects the caller
* cannot open would disclose what other teams use.
*/
describe('WorkflowDependencyRepository node usage', () => {
let repository: WorkflowDependencyRepository;
/** Roles carrying `workflow:read`, as the caller derives them from scopes. */
const readerScope = {
projectRoles: ['project:admin', 'project:editor'],
workflowRoles: ['workflow:owner', 'workflow:editor'],
};
beforeAll(async () => {
await testDb.init();
repository = Container.get(WorkflowDependencyRepository);
});
beforeEach(async () => {
await testDb.truncate([
'WorkflowDependency',
'SharedWorkflow',
'WorkflowEntity',
'ProjectRelation',
'Project',
'User',
]);
});
afterAll(async () => {
await testDb.terminate();
});
/** A workflow holding one node per given type. `createWorkflow` indexes it on the way in, so
* these tests read rows the real indexer would have written. */
const seedWorkflow = async (
project: Project,
nodeTypes: string[],
attributes: Partial<Parameters<typeof createWorkflow>[0]> = {},
) =>
await createWorkflow(
{
nodes: nodeTypes.map((type, index) => ({
id: `node-${index}-${type}`,
name: `${type}-${index}`,
parameters: {},
position: [0, 0] as [number, number],
type,
typeVersion: 1,
})),
...attributes,
},
project,
);
describe('countNodeTypeUsage()', () => {
it('counts only workflows in projects the user belongs to', async () => {
//
// ARRANGE
//
const member = await createMember();
const theirs = await createTeamProject('theirs');
await linkUserToProject(member, theirs, 'project:editor');
const someoneElses = await createTeamProject('someone-elses');
await seedWorkflow(theirs, [ANTHROPIC]);
await seedWorkflow(theirs, [ANTHROPIC, SLACK]);
await seedWorkflow(someoneElses, [OPENAI]);
//
// ACT
//
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
//
// ASSERT
//
expect(result.workflowsInScope).toBe(2);
expect(result.nodeTypes).toEqual([
{ nodeType: ANTHROPIC, workflowCount: 2 },
{ nodeType: SLACK, workflowCount: 1 },
]);
// The whole point of the scoping: the other project's choice must not leak.
expect(result.nodeTypes.map((row) => row.nodeType)).not.toContain(OPENAI);
});
it('narrows to one project when given a projectId', async () => {
const member = await createMember();
const first = await createTeamProject('first');
const second = await createTeamProject('second');
await linkUserToProject(member, first, 'project:editor');
await linkUserToProject(member, second, 'project:editor');
await seedWorkflow(first, [ANTHROPIC]);
await seedWorkflow(second, [OPENAI]);
const result = await repository.countNodeTypeUsage(
member,
{ ...readerScope, projectId: first.id },
50,
);
expect(result.workflowsInScope).toBe(1);
expect(result.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 1 }]);
});
it('counts a node type once per workflow however many instances it holds', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
await seedWorkflow(project, [SLACK, SLACK, SLACK]);
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
expect(result.nodeTypes).toEqual([{ nodeType: SLACK, workflowCount: 1 }]);
});
it('excludes archived workflows from the counts and the denominator', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
await seedWorkflow(project, [ANTHROPIC]);
await seedWorkflow(project, [OPENAI], { isArchived: true });
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
expect(result.workflowsInScope).toBe(1);
expect(result.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 1 }]);
});
it('reads draft rows, not published ones', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
const workflow = await seedWorkflow(project, [ANTHROPIC]);
// A published snapshot of the same workflow, which must not double-count or override
// what the draft says the workflow currently contains.
const published = new WorkflowDependencies(workflow.id, 2, 'published-version-id');
published.add({ dependencyType: 'nodeType', dependencyKey: OPENAI, dependencyInfo: null });
await repository.updateDependenciesForWorkflow(workflow.id, published);
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
expect(result.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 1 }]);
});
it('counts every project for a user with global workflow:read', async () => {
const owner = await createOwner();
const first = await createTeamProject('first');
const second = await createTeamProject('second');
await seedWorkflow(first, [ANTHROPIC]);
await seedWorkflow(second, [OPENAI]);
const result = await repository.countNodeTypeUsage(owner, readerScope, 50);
expect(result.workflowsInScope).toBe(2);
expect(result.nodeTypes).toEqual([
{ nodeType: ANTHROPIC, workflowCount: 1 },
{ nodeType: OPENAI, workflowCount: 1 },
]);
});
it('returns nothing for a user who belongs to no project', async () => {
const member = await createMember();
const someoneElses = await createTeamProject('someone-elses');
await seedWorkflow(someoneElses, [ANTHROPIC]);
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
expect(result).toEqual({ workflowsInScope: 0, nodeTypes: [], truncated: false });
});
it('caps the histogram at the limit and says the list is cut', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
// One workflow per type, so every type has the same count and only the limit decides
// how many come back.
await seedWorkflow(project, [ANTHROPIC]);
await seedWorkflow(project, [OPENAI]);
await seedWorkflow(project, [SLACK]);
const capped = await repository.countNodeTypeUsage(member, readerScope, 2);
const complete = await repository.countNodeTypeUsage(member, readerScope, 50);
expect(capped.nodeTypes).toHaveLength(2);
expect(capped.truncated).toBe(true);
// The denominator still describes the whole scope, not the shown rows.
expect(capped.workflowsInScope).toBe(3);
expect(complete.nodeTypes).toHaveLength(3);
expect(complete.truncated).toBe(false);
});
it('keeps the most-used types when it cuts', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
await seedWorkflow(project, [ANTHROPIC, SLACK]);
await seedWorkflow(project, [ANTHROPIC]);
await seedWorkflow(project, [OPENAI]);
const result = await repository.countNodeTypeUsage(member, readerScope, 1);
expect(result.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 2 }]);
expect(result.truncated).toBe(true);
});
});
describe('findWorkflowsUsingNodeType()', () => {
it('returns the workflows using a type, most recently updated first, and scopes them', async () => {
const member = await createMember();
const theirs = await createTeamProject('theirs');
await linkUserToProject(member, theirs, 'project:editor');
const someoneElses = await createTeamProject('someone-elses');
const older = await seedWorkflow(theirs, [ANTHROPIC], { name: 'older' });
const newer = await seedWorkflow(theirs, [ANTHROPIC], { name: 'newer' });
await seedWorkflow(theirs, [OPENAI], { name: 'different type' });
await seedWorkflow(someoneElses, [ANTHROPIC], { name: 'out of scope' });
// Make the ordering deterministic rather than relying on insertion timing.
await Container.get(WorkflowRepository).update(older.id, {
updatedAt: new Date('2020-01-01T00:00:00.000Z'),
});
await Container.get(WorkflowRepository).update(newer.id, {
updatedAt: new Date('2030-01-01T00:00:00.000Z'),
});
const rows = await repository.findWorkflowsUsingNodeType(member, readerScope, ANTHROPIC, 10);
expect(rows.map((row) => row.name)).toEqual(['newer', 'older']);
expect(rows[0].workflowId).toBe(newer.id);
expect(rows[0].updatedAt).toBeInstanceOf(Date);
});
it('respects the limit', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
await seedWorkflow(project, [SLACK]);
await seedWorkflow(project, [SLACK]);
await seedWorkflow(project, [SLACK]);
const rows = await repository.findWorkflowsUsingNodeType(member, readerScope, SLACK, 2);
expect(rows).toHaveLength(2);
});
});
describe('scale', () => {
// The design this replaces passed the workflow ids in as bind parameters, which caps out at
// SQLite's 999-variable limit. Scope is a subquery here, so the assertion is that the counts
// are exact past that point — not merely that nothing threw.
it('returns exact counts for a project larger than the bind-parameter limit', async () => {
const member = await createMember();
const project = await createTeamProject('large');
await linkUserToProject(member, project, 'project:editor');
const workflowCount = 1_200;
const anthropicCount = 700;
const workflowRepository = Container.get(WorkflowRepository);
const sharedWorkflowRepository = Container.get(SharedWorkflowRepository);
// Built directly rather than through `createWorkflow` so the seed stays a few bulk
// inserts instead of 1,200 round trips per table.
const workflows = Array.from({ length: workflowCount }, (_, index) =>
workflowRepository.create({
name: `workflow-${index}`,
active: false,
isArchived: false,
nodes: [],
connections: {},
nodeGroups: [],
settings: {},
versionId: `version-${index}`,
}),
);
// Chunked: a single multi-row INSERT of this many rows exceeds SQLite's expression-tree
// depth limit, which is a property of the seed, not of the query under test.
const insertInChunks = async <T>(rows: T[], insert: (chunk: T[]) => Promise<unknown>) => {
for (let index = 0; index < rows.length; index += 200) {
await insert(rows.slice(index, index + 200));
}
};
await insertInChunks(workflows, async (chunk) => await workflowRepository.insert(chunk));
await insertInChunks(
workflows.map((workflow) =>
sharedWorkflowRepository.create({
projectId: project.id,
workflowId: workflow.id,
role: 'workflow:owner',
}),
),
async (chunk) => await sharedWorkflowRepository.insert(chunk),
);
await insertInChunks(
workflows.map((workflow, index) => ({
workflowId: workflow.id,
workflowVersionId: 1,
publishedVersionId: null,
dependencyType: 'nodeType' as const,
dependencyKey: index < anthropicCount ? ANTHROPIC : OPENAI,
dependencyInfo: null,
indexVersionId: 1,
})),
async (chunk) => await repository.insert(chunk),
);
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
expect(result.workflowsInScope).toBe(workflowCount);
expect(result.nodeTypes).toEqual([
{ nodeType: ANTHROPIC, workflowCount: anthropicCount },
{ nodeType: OPENAI, workflowCount: workflowCount - anthropicCount },
]);
});
});
});

View File

@ -0,0 +1,193 @@
import {
createTeamProject,
createWorkflow,
linkUserToProject,
testDb,
} from '@n8n/backend-test-utils';
import { LicenseState } from '@n8n/backend-common';
import type { Project } from '@n8n/db';
import { Container } from '@n8n/di';
import { WorkflowDependencyQueryService } from '@/modules/workflow-index/workflow-dependency-query.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { createMember, createOwner } from '../../shared/db/users';
import { LicenseMocker } from '../../shared/license';
const ANTHROPIC = '@n8n/n8n-nodes-langchain.lmChatAnthropic';
const SLACK = 'n8n-nodes-base.slack';
/**
* The repository tests pass role slugs in directly. This one goes through the service, so the
* scopes-to-roles step runs for real — if `workflow:read` ever stopped mapping to the roles the
* scope subquery matches on, the aggregate would silently return nothing and only this would catch it.
*/
describe('WorkflowDependencyQueryService.getNodeTypeUsage', () => {
let service: WorkflowDependencyQueryService;
let workflowService: WorkflowService;
beforeAll(async () => {
await testDb.init();
service = Container.get(WorkflowDependencyQueryService);
workflowService = Container.get(WorkflowService);
// `WorkflowService.getMany` reads the license to decide on the resolvable-credentials flag,
// and throws without a provider set.
new LicenseMocker().mockLicenseState(Container.get(LicenseState));
});
beforeEach(async () => {
await testDb.truncate([
'WorkflowDependency',
'SharedWorkflow',
'WorkflowEntity',
'ProjectRelation',
'Project',
'User',
]);
});
afterAll(async () => {
await testDb.terminate();
});
const seedWorkflow = async (project: Project, nodeTypes: string[], name?: string) =>
await createWorkflow(
{
...(name ? { name } : {}),
nodes: nodeTypes.map((type, index) => ({
id: `node-${index}-${type}`,
name: `${type}-${index}`,
parameters: {},
position: [0, 0] as [number, number],
type,
typeVersion: 1,
})),
},
project,
);
it('resolves real roles and returns the histogram for the projects a member belongs to', async () => {
const member = await createMember();
const theirs = await createTeamProject('theirs');
await linkUserToProject(member, theirs, 'project:editor');
const someoneElses = await createTeamProject('someone-elses');
await seedWorkflow(theirs, [ANTHROPIC]);
await seedWorkflow(theirs, [ANTHROPIC, SLACK]);
await seedWorkflow(someoneElses, [SLACK]);
const result = await service.getNodeTypeUsage(member);
expect(result.workflowsInScope).toBe(2);
expect(result.nodeTypes).toEqual([
{ nodeType: ANTHROPIC, workflowCount: 2 },
{ nodeType: SLACK, workflowCount: 1 },
]);
expect(result.workflows).toBeUndefined();
});
it('names the workflows using a type, with the denominator', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
await seedWorkflow(project, [ANTHROPIC], 'uses anthropic');
await seedWorkflow(project, [SLACK], 'uses slack');
const result = await service.getNodeTypeUsage(member, { nodeType: ANTHROPIC });
expect(result.workflowsInScope).toBe(2);
expect(result.workflows?.map((workflow) => workflow.name)).toEqual(['uses anthropic']);
expect(result.nodeTypes).toBeUndefined();
expect(result.truncated).toBeUndefined();
});
it('reports truncation when more workflows use the type than the limit allows', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
await seedWorkflow(project, [SLACK]);
await seedWorkflow(project, [SLACK]);
await seedWorkflow(project, [SLACK]);
const result = await service.getNodeTypeUsage(member, { nodeType: SLACK, limit: 2 });
expect(result.workflows).toHaveLength(2);
expect(result.truncated).toBe(true);
});
// The `nodeTypes` filter on `list` resolves through `applyNodeTypesFilter`, which had no test
// anywhere before this. Asserting the adapter forwards the option proves nothing about whether
// the listing actually narrows, so assert the returned set.
describe('nodeTypes filter on the workflow listing', () => {
it('returns only workflows containing one of the given node types', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
await seedWorkflow(project, [ANTHROPIC], 'has anthropic');
await seedWorkflow(project, [ANTHROPIC, SLACK], 'has both');
await seedWorkflow(project, [SLACK], 'has slack');
const { workflows, count } = await workflowService.getMany(member, {
filter: { nodeTypes: [ANTHROPIC] },
});
expect(count).toBe(2);
expect(workflows.map((workflow) => workflow.name).sort()).toEqual([
'has anthropic',
'has both',
]);
});
it('matches a workflow that holds any one of several requested types', async () => {
const member = await createMember();
const project = await createTeamProject('project');
await linkUserToProject(member, project, 'project:editor');
await seedWorkflow(project, [ANTHROPIC], 'anthropic only');
await seedWorkflow(project, [SLACK], 'slack only');
await seedWorkflow(project, ['n8n-nodes-base.noOp'], 'neither');
const { count } = await workflowService.getMany(member, {
filter: { nodeTypes: [ANTHROPIC, SLACK] },
});
expect(count).toBe(2);
});
it('cannot reach a workflow the user has no access to', async () => {
const member = await createMember();
const theirs = await createTeamProject('theirs');
await linkUserToProject(member, theirs, 'project:editor');
const someoneElses = await createTeamProject('someone-elses');
await seedWorkflow(theirs, [ANTHROPIC], 'mine');
await seedWorkflow(someoneElses, [ANTHROPIC], 'not mine');
const { workflows, count } = await workflowService.getMany(member, {
filter: { nodeTypes: [ANTHROPIC] },
});
expect(count).toBe(1);
expect(workflows.map((workflow) => workflow.name)).toEqual(['mine']);
});
});
it('narrows to one project when asked', async () => {
const owner = await createOwner();
const first = await createTeamProject('first');
const second = await createTeamProject('second');
await seedWorkflow(first, [ANTHROPIC]);
await seedWorkflow(second, [SLACK]);
const everything = await service.getNodeTypeUsage(owner);
const narrowed = await service.getNodeTypeUsage(owner, { projectId: first.id });
expect(everything.workflowsInScope).toBe(2);
expect(narrowed.workflowsInScope).toBe(1);
expect(narrowed.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 1 }]);
});
});