feat(core): Let the agent read which nodes the workflows in scope use (no-changelog)

The dependency index already records a row per node type per workflow, kept
current on every save, and the workflow repository already resolves a
`nodeTypes` filter against it. No caller ever supplied one, so "which workflows
use Postgres" meant fetching every workflow and parsing its nodes, and "what is
this folder built out of" was not answerable at all.

- `workflows(list)` gains `nodeTypes`. It matches the node a workflow actually
  contains, so a workflow merely NAMED for a service is correctly excluded and
  one that uses it under an unrelated name is correctly found.
- `workflows(node-usage)` returns the node-type histogram for a scope, most-used
  first, and composes with the folder arguments. Offered only where the adapter
  can answer it, so the agent is never shown a summary it would get an error from.
- Counts DISTINCT workflows rather than node instances: three HTTP Request nodes
  in one workflow are one user of the type, which is the question being asked.
- The scan is capped, and a capped scan says its counts are a lower bound —
  silently truncating would read as a total, and these are numbers a caller
  quotes back to the user.
- Gated separately from folder context. The two answer different questions
  (where something lives vs what it is made of), and one switch could not tell
  which of them moved a result.

Shared field descriptions are hoisted to constants because the schema sanitizer
requires a single description per field name across the action union.
This commit is contained in:
Arvin Ansari 2026-08-27 18:41:58 +02:00
parent 2b85c805f6
commit 70b3f2fcaf
7 changed files with 473 additions and 25 deletions

View File

@ -43,6 +43,64 @@ export class WorkflowDependencyRepository extends Repository<WorkflowDependency>
) {
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<Array<{ nodeType: string; workflowCount: number }>> {
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<Array<{ nodeType: string; workflowId: string }>> {
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.

View File

@ -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';

View File

@ -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');
});
});

View File

@ -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<typeof listAction>
| z.infer<typeof nodeUsageAction>
| z.infer<typeof getAction>
| z.infer<typeof getJsonAction>
| z.infer<typeof getAsCodeAction>
@ -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<WorkflowAction, string>;
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<Record<WorkflowAction, WorkflowActionSchema>>,
options: WorkflowsToolOptions,
@ -506,6 +548,7 @@ async function handleList(context: InstanceAiContext, input: Extract<Input, { ac
...(input.folderId ? { folderId: input.folderId } : {}),
...(input.folderPath ? { folderPath: input.folderPath } : {}),
...(input.recursive !== undefined ? { recursive: input.recursive } : {}),
...(input.nodeTypes ? { nodeTypes: input.nodeTypes } : {}),
});
// A partial list must never read as the complete inventory: guessed name
@ -549,6 +592,51 @@ async function handleList(context: InstanceAiContext, input: Extract<Input, { ac
};
}
async function handleNodeUsage(
context: InstanceAiContext,
input: Extract<Input, { action: 'node-usage' }>,
) {
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':

View File

@ -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<WorkflowListResult>;
/**
* 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<NodeUsageResult>;
get(workflowId: string): Promise<WorkflowDetail>;
/** Get the workflow as the SDK's WorkflowJSON (full node data for generateWorkflowCode).
* Pass a versionId to get a past version's graph instead of the current draft. */

View File

@ -0,0 +1,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';
}

View File

@ -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;