diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 2829a5988a8..eed1156f10e 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 594be14841b..7b0a904620d 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -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; diff --git a/packages/@n8n/config/src/configs/instance-ai.config.ts b/packages/@n8n/config/src/configs/instance-ai.config.ts index 24342ac616e..f791409b139 100644 --- a/packages/@n8n/config/src/configs/instance-ai.config.ts +++ b/packages/@n8n/config/src/configs/instance-ai.config.ts @@ -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. diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 602324457df..073f4da0cc9 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -368,6 +368,7 @@ describe('GlobalConfig', () => { mcpConnectionsEnabled: false, canvasNodeContextEnabled: false, instanceAiSetupPanelEnabled: false, + nodeUsageEnabled: false, activationCapped: false, activationLockMessageThreshold: 1, }, diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index 8c4fc9f81fb..22c145da702 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -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'; diff --git a/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts b/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts index 336504705d2..64a5d61acb0 100644 --- a/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts @@ -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 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 { + 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> { + 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. diff --git a/packages/@n8n/instance-ai/src/index.ts b/packages/@n8n/instance-ai/src/index.ts index 47080bd74d4..6f18493720a 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -668,6 +668,7 @@ export type { WorkflowTaskService, WorkflowSummary, WorkflowDetail, + NodeUsageResult, WorkflowNode, WorkflowVersionSummary, WorkflowVersionDetail, diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index 9ad16d9aaab..172ff24eb51 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -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 { + const schema = (tool as { inputSchema: { parse: (input: unknown) => Record } }) + .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'] }), + ); + }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index e2ace61bdbf..f460387c4fe 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -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 + | z.infer | z.infer | z.infer | z.infer @@ -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> { 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, +) { + 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) { const { workflows, total, totalInScope } = await context.workflowService.list({ limit: input.limit, @@ -493,6 +596,7 @@ async function handleList(context: InstanceAiContext, input: Extract; + /** 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; + /** + * 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; get(workflowId: string): Promise; /** 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. */ diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts new file mode 100644 index 00000000000..9f68967caa9 --- /dev/null +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.node-usage.test.ts @@ -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({ id: 'user-1', role: GLOBAL_MEMBER_ROLE }); + +const workflowService = mock(); + +function buildAdapter(options: { dependencyQueryService?: WorkflowDependencyQueryService }) { + const logger = mock(); + const globalConfig = mock({ ai: { allowSendingParameterValues: true } }); + + return new InstanceAiAdapterService( + logger, + globalConfig, + workflowService, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock({ staticCacheDir: '/tmp/test-cache', n8nFolder: '/tmp/test-cache' }), + mock(), + mock(), + new NodeResourceExplorerService( + logger, + mock(), + mock(), + mock(), + mock(), + ), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + undefined, + undefined, + undefined, + options.dependencyQueryService, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(Container, 'get').mockReturnValue(mock()); +}); + +describe('InstanceAiAdapterService node usage', () => { + describe('isNodeUsageEnabled()', () => { + const gateFor = async (flags: Record | Error) => { + const postHogClient = mock(); + 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()) 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(), + }); + + expect(service.createContext(user).workflowService.nodeUsage).toBeUndefined(); + }); + + it('omits nodeUsage when the rollout gate is closed', () => { + const service = buildAdapter({ + dependencyQueryService: mock(), + }); + + 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(), + }); + + expect( + service.createContext(user, { nodeUsageEnabled: true }).workflowService.nodeUsage, + ).toBeDefined(); + }); + }); + + describe('nodeUsage()', () => { + it("defaults to the thread's bound project", async () => { + const dependencyQueryService = mock(); + 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(); + 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(); + 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(), + }); + + 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' }, + }); + }); + }); +}); 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 e24ced43b12..261e8991539 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 @@ -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[1]; } 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 af26ca8ee4d..efff4f674d9 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 @@ -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 = { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index cd39ef5df48..d0aa8cbad15 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -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 { + 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 { + 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, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index 7fa880bb874..95d2fae9363 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -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, }); diff --git a/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts b/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts index 6662c4eec84..41697fe9fcc 100644 --- a/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts +++ b/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts @@ -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>; credMap: Map>; @@ -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 { + // 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, diff --git a/packages/cli/src/posthog/index.ts b/packages/cli/src/posthog/index.ts index d017c989b7e..3d5b8034261 100644 --- a/packages/cli/src/posthog/index.ts +++ b/packages/cli/src/posthog/index.ts @@ -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 }; } } diff --git a/packages/cli/test/integration/database/repositories/workflow-dependency.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow-dependency.repository.test.ts index 65998f9053f..a81aba49605 100644 --- a/packages/cli/test/integration/database/repositories/workflow-dependency.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow-dependency.repository.test.ts @@ -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[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 (rows: T[], insert: (chunk: T[]) => Promise) => { + 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 }, + ]); + }); + }); +}); diff --git a/packages/cli/test/integration/modules/workflow-index/node-type-usage.integration.test.ts b/packages/cli/test/integration/modules/workflow-index/node-type-usage.integration.test.ts new file mode 100644 index 00000000000..a073e475626 --- /dev/null +++ b/packages/cli/test/integration/modules/workflow-index/node-type-usage.integration.test.ts @@ -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 }]); + }); +});