diff --git a/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts b/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts index 336504705d2..facd2f9be4f 100644 --- a/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-dependency.repository.ts @@ -43,6 +43,64 @@ export class WorkflowDependencyRepository extends Repository ) { super(WorkflowDependency, dataSource.manager); } + /** + * How many of the given workflows use each node type, most-used first. + * + * Reads the draft rows (`publishedVersionId IS NULL`) so the answer describes + * what the workflows currently contain rather than what was last published. + * Counts DISTINCT workflows, not node instances: three HTTP Request nodes in + * one workflow are one user of the node type, which is the question callers + * are actually asking. + * + * The caller supplies the workflow ids it has already authorized, so this adds + * no access checks of its own. + */ + async countWorkflowsByNodeType( + workflowIds: string[], + ): Promise> { + if (workflowIds.length === 0) return []; + + const rows = await this.createQueryBuilder('dep') + .select('dep.dependencyKey', 'nodeType') + .addSelect('COUNT(DISTINCT dep.workflowId)', 'workflowCount') + .where('dep.dependencyType = :depType', { depType: 'nodeType' }) + .andWhere('dep.publishedVersionId IS NULL') + .andWhere('dep.workflowId IN (:...workflowIds)', { workflowIds }) + .groupBy('dep.dependencyKey') + .orderBy('COUNT(DISTINCT dep.workflowId)', 'DESC') + .addOrderBy('dep.dependencyKey', 'ASC') + .getRawMany<{ nodeType: string; workflowCount: string | number }>(); + + // Aggregates come back as strings on some drivers. + return rows.map((row) => ({ + nodeType: row.nodeType, + workflowCount: Number(row.workflowCount), + })); + } + + /** + * Which of the given workflows use each of the named node types. + * + * Same draft-row scoping as `countWorkflowsByNodeType`. Returned as pairs + * rather than a map so the caller decides how to group them. + */ + async findWorkflowIdsByNodeType( + workflowIds: string[], + nodeTypes: string[], + ): Promise> { + if (workflowIds.length === 0 || nodeTypes.length === 0) return []; + + return await this.createQueryBuilder('dep') + .select('dep.dependencyKey', 'nodeType') + .addSelect('dep.workflowId', 'workflowId') + .distinct(true) + .where('dep.dependencyType = :depType', { depType: 'nodeType' }) + .andWhere('dep.publishedVersionId IS NULL') + .andWhere('dep.workflowId IN (:...workflowIds)', { workflowIds }) + .andWhere('dep.dependencyKey IN (:...nodeTypes)', { nodeTypes }) + .getRawMany<{ nodeType: string; workflowId: string }>(); + } + /** * 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 fc6454b8063..d7b1c115fc0 100644 --- a/packages/@n8n/instance-ai/src/index.ts +++ b/packages/@n8n/instance-ai/src/index.ts @@ -188,6 +188,7 @@ const loadValidateAttachments = lazyModule( export { MAX_STEPS } from './constants/max-steps'; export { parseModelHeadersJson } from './utils/parse-model-headers'; export { isFolderContextEnabled } from './utils/folder-context-enabled'; +export { isNodeUsageContextEnabled } from './utils/node-usage-context-enabled'; export { resolveCustomModelExperimentDefaultsFromEnv } from './utils/custom-model-defaults'; export { WorkflowSaveConflictError } from './errors/workflow-save-conflict.error'; export { WorkflowNotFoundError } from './errors/workflow-not-found.error'; diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.node-usage.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.node-usage.test.ts new file mode 100644 index 00000000000..4fb1c24783b --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.node-usage.test.ts @@ -0,0 +1,181 @@ +import { executeTool } from '../../__tests__/tool-test-utils'; +import type { InstanceAiContext } from '../../types'; +import { createWorkflowsTool } from '../workflows.tool'; + +vi.mock('../workflows/setup-workflow.service', () => ({ + analyzeWorkflow: vi.fn().mockResolvedValue([]), + applyCredentialHints: vi.fn(), + applyNodeCredentials: vi.fn().mockResolvedValue({ failed: [] }), + applyNodeParameters: vi.fn().mockResolvedValue({ failed: [] }), + applyNodeChanges: vi.fn().mockResolvedValue({ applied: [], failed: [] }), + buildCompletedReport: vi.fn().mockReturnValue([]), +})); + +const emptyList = { workflows: [], total: 0, totalInScope: 0 }; + +function createContext( + overrides: { nodeUsage?: unknown; list?: unknown } = {}, +): InstanceAiContext { + return { + userId: 'user-1', + workflowService: { + list: overrides.list ?? vi.fn().mockResolvedValue(emptyList), + ...('nodeUsage' in overrides ? { nodeUsage: overrides.nodeUsage } : {}), + }, + permissions: {}, + } as unknown as InstanceAiContext; +} + +function inputSchema(tool: unknown): { safeParse: (input: unknown) => { success: boolean } } { + return (tool as { inputSchema: { safeParse: (input: unknown) => { success: boolean } } }) + .inputSchema; +} + +describe('workflows(node-usage)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // The action is only worth offering where an index can answer it; advertising + // it against an adapter that cannot would trade a slow answer for an error. + it('is not offered when the service cannot answer it', () => { + const tool = createWorkflowsTool(createContext(), 'full'); + + expect(inputSchema(tool).safeParse({ action: 'node-usage' }).success).toBe(false); + }); + + it('is offered when the service can answer it', () => { + const nodeUsage = vi + .fn() + .mockResolvedValue({ usage: [], workflowsScanned: 0, totalInScope: 0 }); + const tool = createWorkflowsTool(createContext({ nodeUsage }), 'full'); + + expect(inputSchema(tool).safeParse({ action: 'node-usage' }).success).toBe(true); + }); + + it('returns the histogram most-used first', async () => { + const nodeUsage = vi.fn().mockResolvedValue({ + usage: [ + { nodeType: 'n8n-nodes-base.httpRequest', workflowCount: 7 }, + { nodeType: 'n8n-nodes-base.slack', workflowCount: 2 }, + ], + workflowsScanned: 9, + totalInScope: 9, + }); + const tool = createWorkflowsTool(createContext({ nodeUsage }), 'full'); + + const result = await executeTool(tool, { action: 'node-usage' } as never, {} as never); + + expect(result).toMatchObject({ + usage: [ + { nodeType: 'n8n-nodes-base.httpRequest', workflowCount: 7 }, + { nodeType: 'n8n-nodes-base.slack', workflowCount: 2 }, + ], + workflowsScanned: 9, + totalInScope: 9, + }); + expect(result).not.toHaveProperty('note'); + }); + + // A truncated scan reads as a total unless it says so, and these counts are + // exactly the numbers a caller would quote back to the user. + it('says the counts are a lower bound when the scan was capped', async () => { + const nodeUsage = vi.fn().mockResolvedValue({ + usage: [{ nodeType: 'n8n-nodes-base.slack', workflowCount: 3 }], + workflowsScanned: 500, + totalInScope: 812, + }); + const tool = createWorkflowsTool(createContext({ nodeUsage }), 'full'); + + const result = await executeTool(tool, { action: 'node-usage' } as never, {} as never); + + const note = (result as { note: string }).note; + expect(note).toContain('500 of 812'); + expect(note).toContain('lower bound'); + }); + + it('distinguishes an empty scope from a truncated one', async () => { + const nodeUsage = vi + .fn() + .mockResolvedValue({ usage: [], workflowsScanned: 0, totalInScope: 0 }); + const tool = createWorkflowsTool(createContext({ nodeUsage }), 'full'); + + const result = await executeTool(tool, { action: 'node-usage' } as never, {} as never); + + expect((result as { note: string }).note).toContain('No node usage recorded'); + }); + + it('reports an unresolved folder instead of summarizing a wider scope', async () => { + const nodeUsage = vi.fn().mockResolvedValue({ + usage: [], + workflowsScanned: 0, + totalInScope: 0, + folderResolution: { requested: 'ragdoll', reason: 'not-found', candidates: ['Archive'] }, + }); + const tool = createWorkflowsTool(createContext({ nodeUsage }), 'full'); + + const result = await executeTool( + tool, + { action: 'node-usage', folderPath: 'ragdoll' } as never, + {} as never, + ); + + const note = (result as { note: string }).note; + expect(note).toContain('NOT folder-scoped'); + expect(note).toContain('"Archive"'); + }); + + it('forwards the folder and node-type narrowing to the service', async () => { + const nodeUsage = vi + .fn() + .mockResolvedValue({ usage: [], workflowsScanned: 0, totalInScope: 0 }); + const tool = createWorkflowsTool(createContext({ nodeUsage }), 'full'); + + await executeTool( + tool, + { + action: 'node-usage', + folderPath: 'Clients/Acme', + nodeTypes: ['n8n-nodes-base.postgres'], + recursive: false, + } as never, + {} as never, + ); + + expect(nodeUsage).toHaveBeenCalledWith({ + folderPath: 'Clients/Acme', + nodeTypes: ['n8n-nodes-base.postgres'], + recursive: false, + }); + }); +}); + +describe('workflows(list) nodeTypes filter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forwards nodeTypes so membership is read from the index, not the name', async () => { + const list = vi.fn().mockResolvedValue(emptyList); + const tool = createWorkflowsTool(createContext({ list }), 'full'); + + await executeTool( + tool, + { action: 'list', nodeTypes: ['n8n-nodes-base.slack'] } as never, + {} as never, + ); + + expect(list).toHaveBeenCalledWith( + expect.objectContaining({ nodeTypes: ['n8n-nodes-base.slack'] }), + ); + }); + + it('omits the filter entirely when no node types were asked for', async () => { + const list = vi.fn().mockResolvedValue(emptyList); + const tool = createWorkflowsTool(createContext({ list }), 'full'); + + await executeTool(tool, { action: 'list' } as never, {} as never); + + expect(list.mock.calls[0][0]).not.toHaveProperty('nodeTypes'); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index 7e4a5ce3251..148a3f69cd5 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -20,6 +20,7 @@ import { } from './credentials.tool'; import { isFolderContextEnabled } from '../utils/folder-context-enabled'; import { formatTimestamp } from '../utils/format-timestamp'; +import { isNodeUsageContextEnabled } from '../utils/node-usage-context-enabled'; import { getObservedWorkflowChecksum, rememberCurrentWorkflowChecksum, @@ -64,6 +65,23 @@ import { getReferencedWorkflowIds } from './workflows/workflow-json-utils'; 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.'; +// `list` and `node-usage` share these fields, and the schema sanitizer requires one +// description per shared field, so each has to read correctly for both actions. +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 FOLDER_PATH_FIELD_DESCRIPTION = + 'Restrict to one folder, named the way the user named it — "logsearch", "personal/logsearch", "Clients/Acme". This is the ONLY correct way to address a folder: folder membership is stored, not encoded in workflow names, so a `query` prefix both misses members named differently and picks up non-members that share the prefix. Matched case-insensitively on the full path, then on the folder name. If it does not resolve, the result says so and lists the real folders — never assume the returned set is the folder.'; + +const FOLDER_ID_FIELD_DESCRIPTION = + 'Folder ID, when a previous listing already gave you one (each workflow row carries its `folder`). Prefer `folderPath` when working from what the user said.'; + +const RECURSIVE_FIELD_DESCRIPTION = + 'Whether a folder is read together with its nested subfolders. Defaults to true, which is what a user naming a folder means. Set false only to inspect one level.'; + +const NODE_TYPES_FIELD_DESCRIPTION = + 'Node types to match, e.g. ["n8n-nodes-base.slack"]. Answered from an index, so it costs one call however many workflows exist, and it matches the node a workflow actually contains rather than its name. For `list`: keep only workflows containing one of these. For `node-usage`: report only these types, which answers "is X used anywhere here" without pulling the whole histogram.'; + const listAction = z.object({ action: z .literal('list') @@ -79,24 +97,10 @@ const listAction = z.object({ ? ' If the user named a FOLDER, use `folderPath` — folder membership is not a name prefix, and guessing it here silently returns the wrong set.' : ''), ), - folderPath: z - .string() - .optional() - .describe( - 'List the workflows inside a folder, named the way the user named it — "logsearch", "personal/logsearch", "Clients/Acme". This is the ONLY correct way to answer "what is in folder X": folder membership is stored, not encoded in workflow names, so a `query` prefix both misses members named differently and picks up non-members that share the prefix. Matched case-insensitively on the full path, then on the folder name. If it does not resolve, the result says so and lists the real folders — never assume the returned set is the folder.', - ), - folderId: z - .string() - .optional() - .describe( - 'Folder ID, when a previous listing already gave you one (each workflow row carries its `folder`). Prefer `folderPath` when working from what the user said.', - ), - recursive: z - .boolean() - .optional() - .describe( - 'Whether a folder listing includes nested subfolders. Defaults to true, which is what a user naming a folder means. Set false only to inspect one level.', - ), + folderPath: z.string().optional().describe(FOLDER_PATH_FIELD_DESCRIPTION), + folderId: z.string().optional().describe(FOLDER_ID_FIELD_DESCRIPTION), + recursive: z.boolean().optional().describe(RECURSIVE_FIELD_DESCRIPTION), + nodeTypes: z.array(z.string()).optional().describe(NODE_TYPES_FIELD_DESCRIPTION), limit: z.number().int().positive().max(100).optional().describe('Max results to return'), status: z .enum(['active', 'archived', 'all']) @@ -104,15 +108,24 @@ const listAction = z.object({ .describe( 'Which workflows to list. Defaults to active; use archived to find workflows that can be restored.', ), - scope: z - .enum(['project', 'instance']) - .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.", - ), + scope: z.enum(['project', 'instance']).optional().describe(SCOPE_FIELD_DESCRIPTION), projectId: z.string().optional().describe(PROJECT_ID_FIELD_DESCRIPTION), }); +const nodeUsageAction = z.object({ + action: z + .literal('node-usage') + .describe( + 'Which node types the workflows in scope actually use, and how many workflows use each, most-used first. Read this BEFORE reading workflows when the question is about what an instance, project or folder is built out of — conventions, which integrations are in play, whether something already exists. It answers from an index in one call, where the alternative is fetching every workflow and parsing its nodes.', + ), + scope: z.enum(['project', 'instance']).optional().describe(SCOPE_FIELD_DESCRIPTION), + projectId: z.string().optional().describe(PROJECT_ID_FIELD_DESCRIPTION), + folderPath: z.string().optional().describe(FOLDER_PATH_FIELD_DESCRIPTION), + folderId: z.string().optional().describe(FOLDER_ID_FIELD_DESCRIPTION), + recursive: z.boolean().optional().describe(RECURSIVE_FIELD_DESCRIPTION), + nodeTypes: z.array(z.string()).optional().describe(NODE_TYPES_FIELD_DESCRIPTION), +}); + /** `list` without any way to address a folder — the shape the agent saw before * folder context existed. Registered instead of `listAction` when the capability * is off, so both arms of a measurement run off one binary and the off arm has to @@ -124,6 +137,12 @@ const listActionWithoutFolderScope = listAction.omit({ recursive: true, }); +/** `list` without the dependency-index filter, for the same measurement reason. */ +const listActionWithoutNodeTypes = listAction.omit({ nodeTypes: true }); + +/** Neither capability — the shape the agent saw before any of this work. */ +const listActionWithoutEither = listActionWithoutFolderScope.omit({ nodeTypes: true }); + const getAction = z.object({ action: z .literal('get') @@ -332,6 +351,7 @@ interface WorkflowToolContext { // regardless of which dynamic subset the schema actually includes. type Input = | z.infer + | z.infer | z.infer | z.infer | z.infer @@ -353,6 +373,7 @@ type PublishRollbackResult = { }; export type WorkflowAction = | 'list' + | 'node-usage' | 'get' | 'get-json' | 'get-as-code' @@ -380,6 +401,9 @@ type WorkflowsToolOptionsInput = WorkflowsToolOptions | 'full' | 'orchestrator'; const WORKFLOW_ACTION_ORDER = [ 'list', + // Next to `list` on purpose: it answers the same discovery question more + // cheaply, and ordering drives what the agent reads first in the tool schema. + 'node-usage', 'get', 'get-json', 'get-as-code', @@ -410,6 +434,7 @@ const WORKFLOW_ACTION_LABELS = { 'list-versions': 'list versions', 'restore-version': 'restore versions', 'update-version': 'update version metadata', + 'node-usage': 'summarize node usage', } satisfies Record; function normalizeOptions(options: WorkflowsToolOptionsInput = {}): WorkflowsToolOptions { @@ -424,7 +449,12 @@ function getSupportedWorkflowActionSchemas( const hasVersions = !!context.workflowService.listVersions; return { - list: isFolderContextEnabled() ? listAction : listActionWithoutFolderScope, + list: pickListAction(), + // Absent when the adapter has no dependency index behind it, so the agent is + // never offered a summary it would get an error from. + ...(context.workflowService.nodeUsage && isNodeUsageContextEnabled() + ? { 'node-usage': nodeUsageAction } + : {}), get: getAction, ...(surface !== 'orchestrator' ? { 'get-json': getJsonAction } : {}), 'get-as-code': getAsCodeAction, @@ -445,6 +475,18 @@ function getSupportedWorkflowActionSchemas( }; } +/** The `list` shape for the capabilities this instance exposes. Each gate really + * removes its fields rather than ignoring them, so an arm without a capability + * is not merely told to avoid it. */ +function pickListAction() { + const folders = isFolderContextEnabled(); + const nodeUsage = isNodeUsageContextEnabled(); + if (folders && nodeUsage) return listAction; + if (folders) return listActionWithoutNodeTypes; + if (nodeUsage) return listActionWithoutFolderScope; + return listActionWithoutEither; +} + function getWorkflowActions( supportedSchemas: Partial>, options: WorkflowsToolOptions, @@ -506,6 +548,7 @@ async function handleList(context: InstanceAiContext, input: Extract, +) { + if (!context.workflowService.nodeUsage) { + return { + usage: [], + note: 'Node usage is not available on this instance. Read the workflows you need directly.', + }; + } + + const { usage, workflowsScanned, totalInScope, folderResolution } = + await context.workflowService.nodeUsage({ + ...(input.scope ? { scope: input.scope } : {}), + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.folderId ? { folderId: input.folderId } : {}), + ...(input.folderPath ? { folderPath: input.folderPath } : {}), + ...(input.recursive !== undefined ? { recursive: input.recursive } : {}), + ...(input.nodeTypes ? { nodeTypes: input.nodeTypes } : {}), + }); + + const notes: string[] = []; + if (folderResolution) notes.push(formatFolderResolutionNote(folderResolution)); + // A capped scan reads as a complete answer unless it says otherwise, and the + // counts are the thing a caller would quote back to the user. + if (workflowsScanned < totalInScope) { + notes.push( + `Counted ${workflowsScanned} of ${totalInScope} workflows in scope — these counts are a lower bound, not a total. Narrow with \`folderPath\` or \`projectId\` for exact numbers.`, + ); + } + if (usage.length === 0 && !folderResolution) { + notes.push( + 'No node usage recorded for this scope. Either it holds no workflows, or they have not been indexed yet.', + ); + } + + return { + usage, + workflowsScanned, + totalInScope, + ...(folderResolution ? { folderResolution } : {}), + ...(notes.length > 0 ? { note: notes.join(' ') } : {}), + }; +} + /** Turn a failed folder lookup into an instruction, not just a status. The agent's * default recovery here is to retry as a name filter, which is exactly the * behaviour that produced wrong folder inventories — so the note has to name the @@ -1723,6 +1811,8 @@ export function createWorkflowsTool( .handler(async (input, ctx) => { const workflowInput = input as Input; switch (workflowInput.action) { + case 'node-usage': + return await handleNodeUsage(context, workflowInput); case 'list': return await handleList(context, workflowInput); case 'get': diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 8a33adcf474..d1922051746 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -319,6 +319,20 @@ export interface WorkflowListResult { folderResolution?: FolderResolutionFailure; } +export interface NodeUsageResult { + /** Node types in scope, most-used first. */ + usage: Array<{ nodeType: string; workflowCount: number }>; + /** Workflows the histogram was computed over. */ + workflowsScanned: number; + /** + * Total workflows in scope. Greater than `workflowsScanned` means the scan hit + * its cap and the counts are a floor, not a total — say so rather than + * reporting them as complete. + */ + totalInScope: number; + folderResolution?: FolderResolutionFailure; +} + /** Why a requested folder did not resolve, plus the folders that do exist so the * caller can pick one instead of guessing again. */ export interface FolderResolutionFailure { @@ -354,7 +368,26 @@ export interface InstanceAiWorkflowService { /** Include workflows in nested subfolders. Default true — a user naming a * folder means its contents, and n8n folders nest. */ recursive?: boolean; + /** + * Keep only workflows containing at least one node of these types + * (`n8n-nodes-base.slack`). Answered from the dependency index rather than + * by reading graphs, so it costs one join instead of a fetch per workflow. + */ + nodeTypes?: string[]; }): Promise; + /** + * Which node types the workflows in scope actually use, and how many workflows + * use each. Present only where the dependency index is available. + */ + nodeUsage?(options?: { + projectId?: string; + scope?: 'project' | 'instance'; + folderId?: string; + folderPath?: string; + recursive?: boolean; + /** Restrict the histogram to these node types instead of reporting all. */ + nodeTypes?: 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/@n8n/instance-ai/src/utils/node-usage-context-enabled.ts b/packages/@n8n/instance-ai/src/utils/node-usage-context-enabled.ts new file mode 100644 index 00000000000..2624f3b3be4 --- /dev/null +++ b/packages/@n8n/instance-ai/src/utils/node-usage-context-enabled.ts @@ -0,0 +1,11 @@ +/** + * Whether the agent can read the workflow dependency index: the `nodeTypes` + * filter on `workflows(action="list")` and the `node-usage` action. + * + * Separate from the folder gate so the two can be measured apart — folders answer + * "where does this live", node usage answers "what is it built out of", and a + * combined switch could not tell which one moved a result. Defaults to ON. + */ +export function isNodeUsageContextEnabled(): boolean { + return process.env.N8N_INSTANCE_AI_NODE_USAGE_CONTEXT_ENABLED !== 'false'; +} 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 eb49ccde4d3..d49b7e4a3c4 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 @@ -21,6 +21,7 @@ import { WorkflowEntity, WorkflowRepository, FolderRepository, + WorkflowDependencyRepository, } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import type { @@ -293,6 +294,7 @@ export class InstanceAiAdapterService { // Appended rather than grouped with `folderService`: existing tests construct // this service positionally, so inserting mid-list renames every later index. private readonly folderRepository?: FolderRepository, + private readonly workflowDependencyRepository?: WorkflowDependencyRepository, ) { this.logger = logger.scoped('instance-ai'); this.allowSendingParameterValues = globalConfig.ai.allowSendingParameterValues; @@ -621,6 +623,7 @@ export class InstanceAiAdapterService { collaborationService, folderService, folderRepository, + workflowDependencyRepository, projectService, } = this; const logger = this.logger; @@ -768,6 +771,9 @@ export class InstanceAiAdapterService { ...scopeFilter, ...(options?.query ? { query: options.query } : {}), ...(folderIds ? { parentFolderIds: folderIds } : {}), + // Resolved against the dependency index by the repository, so this + // answers "which workflows use X" without reading any graph. + ...(options?.nodeTypes?.length ? { nodeTypes: options.nodeTypes } : {}), }; const { workflows, count } = await workflowService.getMany(user, { @@ -828,6 +834,70 @@ export class InstanceAiAdapterService { }; }, + async nodeUsage(options) { + const targetProjectId = + options?.projectId ?? (options?.scope !== 'instance' ? boundProjectId : undefined); + + const wantsFolderScope = Boolean(options?.folderId ?? options?.folderPath); + const foldersInScope = wantsFolderScope + ? await readFoldersInScope(targetProjectId) + : undefined; + + let folderResolution: FolderResolutionFailure | undefined; + let resolvedFolderId: string | undefined; + if (wantsFolderScope) { + const resolved = resolveRequestedFolder( + { folderId: options?.folderId, folderPath: options?.folderPath }, + foldersInScope, + ); + if ('folderId' in resolved) { + resolvedFolderId = resolved.folderId; + } else { + folderResolution = { + requested: options?.folderPath ?? options?.folderId ?? '', + ...resolved, + }; + } + } + + const folderIds = + resolvedFolderId === undefined + ? undefined + : options?.recursive === false + ? [resolvedFolderId] + : collectFolderSubtree(resolvedFolderId, foldersInScope); + + const filter = { + isArchived: false, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(folderIds ? { parentFolderIds: folderIds } : {}), + }; + + // Ids only: the histogram is computed in the index, and pulling graphs + // here would reintroduce exactly the cost this action removes. + const { workflows, count } = await workflowService.getMany(user, { + take: NODE_USAGE_SCAN_LIMIT, + filter, + select: { id: true }, + }); + const workflowIds = workflows.map((workflow) => workflow.id); + + const counted = workflowDependencyRepository + ? await workflowDependencyRepository.countWorkflowsByNodeType(workflowIds) + : []; + const requested = options?.nodeTypes; + const usage = requested?.length + ? counted.filter((entry) => requested.includes(entry.nodeType)) + : counted; + + return { + usage, + workflowsScanned: workflowIds.length, + totalInScope: count, + ...(folderResolution ? { folderResolution } : {}), + }; + }, + async get(workflowId: string) { const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, [ 'workflow:read', @@ -4026,6 +4096,10 @@ function readHomeProject(workflow: object): { id: string; name: string } | undef * where guessing harder helps, and the miss is reported rather than hidden. */ const FOLDER_SCAN_LIMIT = 200; +/** Workflows a single node-usage histogram is computed over. Past this the answer + * is reported as a lower bound rather than silently truncated. */ +const NODE_USAGE_SCAN_LIMIT = 500; + /** Folder paths offered back on a failed lookup — enough to choose from without * turning an error into an inventory dump. */ const FOLDER_CANDIDATE_LIMIT = 20;