mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Let Instance AI read node-type usage from the dependency index (#37539)
This commit is contained in:
parent
69190dddde
commit
47c9644392
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -368,6 +368,7 @@ describe('GlobalConfig', () => {
|
|||
mcpConnectionsEnabled: false,
|
||||
canvasNodeContextEnabled: false,
|
||||
instanceAiSetupPanelEnabled: false,
|
||||
nodeUsageEnabled: false,
|
||||
activationCapped: false,
|
||||
activationLockMessageThreshold: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -2,10 +2,26 @@ import { DatabaseConfig } from '@n8n/config';
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, EntityManager, IsNull, LessThan, Repository, Not } from '@n8n/typeorm';
|
||||
|
||||
import { WorkflowDependency } from '../entities';
|
||||
import { SharedWorkflowRepository } from './shared-workflow.repository';
|
||||
import type { User } from '../entities';
|
||||
import { WorkflowDependency, WorkflowEntity } from '../entities';
|
||||
|
||||
const INDEX_VERSION_ID = 1;
|
||||
|
||||
/**
|
||||
* Which workflows an aggregate over the index may span. The roles come from the caller
|
||||
* because they are derived from scopes, which live above this layer; everything else about
|
||||
* readability is decided here by the same subquery the workflow listing uses.
|
||||
*/
|
||||
export interface NodeUsageScope {
|
||||
/** Project roles carrying `workflow:read`, as `RoleService.rolesWithScope` returns them. */
|
||||
projectRoles: string[];
|
||||
/** Workflow roles carrying `workflow:read`. */
|
||||
workflowRoles: string[];
|
||||
/** Narrow to a single project. Omit to span every project the user can read. */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to collect workflow dependencies before writing them to the database.
|
||||
*/
|
||||
|
|
@ -40,9 +56,132 @@ export class WorkflowDependencyRepository extends Repository<WorkflowDependency>
|
|||
constructor(
|
||||
dataSource: DataSource,
|
||||
private readonly databaseConfig: DatabaseConfig,
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
) {
|
||||
super(WorkflowDependency, dataSource.manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many workflows in scope use each node type, most-used first.
|
||||
*
|
||||
* Answers "what does this project reach for" from the index instead of by fetching every
|
||||
* workflow and reading its nodes, which is the only other route: the workflow listing filters
|
||||
* on name alone.
|
||||
*/
|
||||
async countNodeTypeUsage(
|
||||
user: User,
|
||||
scope: NodeUsageScope,
|
||||
limit: number,
|
||||
): Promise<{
|
||||
workflowsInScope: number;
|
||||
nodeTypes: Array<{ nodeType: string; workflowCount: number }>;
|
||||
truncated: boolean;
|
||||
}> {
|
||||
const rows = await this.buildScopedDependencyQuery(user, scope)
|
||||
.andWhere('dependency.dependencyType = :dependencyType', { dependencyType: 'nodeType' })
|
||||
.select('dependency.dependencyKey', 'nodeType')
|
||||
// DISTINCT because three HTTP Request nodes in one workflow are one user of the type,
|
||||
// and because a workflow shared into several projects matches the scope subquery once
|
||||
// per row it holds there.
|
||||
.addSelect('COUNT(DISTINCT dependency.workflowId)', 'workflowCount')
|
||||
.groupBy('dependency.dependencyKey')
|
||||
.orderBy('COUNT(DISTINCT dependency.workflowId)', 'DESC')
|
||||
.addOrderBy('dependency.dependencyKey', 'ASC')
|
||||
// One over the limit, so a cut list is reported as cut rather than guessed at. The
|
||||
// histogram has to be bounded: an instance-wide read on a large estate would otherwise
|
||||
// return hundreds of rows, which is the cost this surface exists to avoid.
|
||||
.limit(limit + 1)
|
||||
.getRawMany<{ nodeType: string; workflowCount: number | string }>();
|
||||
|
||||
// Two queries, not one transaction: the counts and the denominator can disagree by a
|
||||
// workflow if one is written between them. Reading both here still beats making every
|
||||
// caller pair them up, and the drift is a row, not a wrong shape.
|
||||
const workflowsInScope = await this.countWorkflowsInScope(user, scope);
|
||||
|
||||
return {
|
||||
workflowsInScope,
|
||||
nodeTypes: rows.slice(0, limit).map((row) => ({
|
||||
nodeType: row.nodeType,
|
||||
workflowCount: Number(row.workflowCount),
|
||||
})),
|
||||
truncated: rows.length > limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How many workflows the counts are out of. Every indexed workflow carries at least a
|
||||
* `workflowIndexed` placeholder row, so counting index rows without a type filter counts
|
||||
* workflows; one not yet indexed is absent from the numerator and the denominator alike.
|
||||
*/
|
||||
async countWorkflowsInScope(user: User, scope: NodeUsageScope): Promise<number> {
|
||||
const total = await this.buildScopedDependencyQuery(user, scope)
|
||||
.select('COUNT(DISTINCT dependency.workflowId)', 'total')
|
||||
.getRawOne<{ total: number | string }>();
|
||||
|
||||
// Postgres returns COUNT as a bigint string.
|
||||
return Number(total?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which workflows in scope use a given node type, most recently updated first.
|
||||
*
|
||||
* The rung below the histogram: once it says a node type is the house choice, this says which
|
||||
* workflow to read as the current example.
|
||||
*/
|
||||
async findWorkflowsUsingNodeType(
|
||||
user: User,
|
||||
scope: NodeUsageScope,
|
||||
nodeType: string,
|
||||
limit: number,
|
||||
): Promise<Array<{ workflowId: string; name: string; updatedAt: Date }>> {
|
||||
const rows = await this.buildScopedDependencyQuery(user, scope)
|
||||
.andWhere('dependency.dependencyType = :dependencyType', { dependencyType: 'nodeType' })
|
||||
.andWhere('dependency.dependencyKey = :nodeType', { nodeType })
|
||||
.select('dependency.workflowId', 'workflowId')
|
||||
// Grouped because the same workflow can hold several rows for one node type, so the
|
||||
// aggregates pick a single name and timestamp per workflow.
|
||||
.addSelect('MAX(workflow.name)', 'name')
|
||||
.addSelect('MAX(workflow.updatedAt)', 'updatedAt')
|
||||
.groupBy('dependency.workflowId')
|
||||
.orderBy('MAX(workflow.updatedAt)', 'DESC')
|
||||
.limit(limit)
|
||||
.getRawMany<{ workflowId: string; name: string; updatedAt: Date | string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
workflowId: row.workflowId,
|
||||
name: row.name,
|
||||
// SQLite hands back the stored string; Postgres hands back a Date.
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt : new Date(row.updatedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Index rows the user may read, scoped by the same subquery the workflow listing is built on
|
||||
* (`SharedWorkflowRepository.buildSharedWorkflowIdsSubquery`) rather than by a list of ids the
|
||||
* caller resolved first. Reusing it keeps this on one access path — a second one is the likeliest
|
||||
* thing here to drift into a leak — and keeps the scope out of the bind parameters, so the query
|
||||
* does not grow with the estate.
|
||||
*/
|
||||
private buildScopedDependencyQuery(user: User, scope: NodeUsageScope) {
|
||||
const readable = this.sharedWorkflowRepository.buildSharedWorkflowIdsSubquery(user, {
|
||||
projectRoles: scope.projectRoles,
|
||||
workflowRoles: scope.workflowRoles,
|
||||
...(scope.projectId ? { projectId: scope.projectId } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
this.createQueryBuilder('dependency')
|
||||
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = dependency.workflowId')
|
||||
// Draft rows describe what a workflow currently contains; what someone is building is a
|
||||
// better statement of preference than what happens to be published.
|
||||
.where('dependency.publishedVersionId IS NULL')
|
||||
// Archived workflows are what a project used to do.
|
||||
.andWhere('workflow.isArchived = :isArchived', { isArchived: false })
|
||||
.andWhere(`dependency.workflowId IN (${readable.getQuery()})`)
|
||||
.setParameters(readable.getParameters())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the dependencies for a given workflow.
|
||||
* Uses the workflowVersionId to ensure consistency between the workflow and dependency tables.
|
||||
|
|
|
|||
|
|
@ -668,6 +668,7 @@ export type {
|
|||
WorkflowTaskService,
|
||||
WorkflowSummary,
|
||||
WorkflowDetail,
|
||||
NodeUsageResult,
|
||||
WorkflowNode,
|
||||
WorkflowVersionSummary,
|
||||
WorkflowVersionDetail,
|
||||
|
|
|
|||
|
|
@ -164,6 +164,14 @@ function getDescription(tool: unknown): string {
|
|||
return (tool as { description: string }).description;
|
||||
}
|
||||
|
||||
/** Parse through the tool's real input schema, so assertions see what the handler would get —
|
||||
* zod strips fields the schema does not declare rather than rejecting them. */
|
||||
function parseInput(tool: unknown, input: unknown): Record<string, unknown> {
|
||||
const schema = (tool as { inputSchema: { parse: (input: unknown) => Record<string, unknown> } })
|
||||
.inputSchema;
|
||||
return schema.parse(input);
|
||||
}
|
||||
|
||||
describe('workflows tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -3535,3 +3543,146 @@ describe('workflows tool', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('node usage', () => {
|
||||
const withNodeUsage = (result: unknown = { workflowsInScope: 0, nodeTypes: [] }) => {
|
||||
const context = createMockContext();
|
||||
context.workflowService.nodeUsage = vi.fn().mockResolvedValue(result);
|
||||
return context;
|
||||
};
|
||||
|
||||
describe('capability gate', () => {
|
||||
// The host attaches `nodeUsage` only when the dependency index is wired behind it, so the
|
||||
// agent must never be offered an action or a filter that would error.
|
||||
it('registers the action only when the service exposes nodeUsage', () => {
|
||||
expect(
|
||||
getInputSchema(createWorkflowsTool(withNodeUsage(), 'full')).safeParse({
|
||||
action: 'node-usage',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
getInputSchema(createWorkflowsTool(createMockContext(), 'full')).safeParse({
|
||||
action: 'node-usage',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// The field is omitted from the schema rather than ignored downstream, so zod strips it and
|
||||
// the handler cannot receive it — which is also what keeps it out of the schema the model reads.
|
||||
it('offers the nodeTypes filter on list under the same condition', () => {
|
||||
const parseList = (context: InstanceAiContext) =>
|
||||
parseInput(createWorkflowsTool(context, 'full'), {
|
||||
action: 'list',
|
||||
nodeTypes: ['n8n-nodes-base.slack'],
|
||||
});
|
||||
|
||||
expect(parseList(withNodeUsage())).toMatchObject({ nodeTypes: ['n8n-nodes-base.slack'] });
|
||||
expect(parseList(createMockContext())).not.toHaveProperty('nodeTypes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('histogram', () => {
|
||||
it('returns the counts with the denominator and states the surface limits', async () => {
|
||||
const context = withNodeUsage({
|
||||
workflowsInScope: 10,
|
||||
nodeTypes: [{ nodeType: '@n8n/n8n-nodes-langchain.lmChatAnthropic', workflowCount: 10 }],
|
||||
});
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
|
||||
const result = await executeTool<{
|
||||
workflowsInScope: number;
|
||||
nodeTypes: Array<{ nodeType: string; workflowCount: number }>;
|
||||
note: string;
|
||||
}>(tool, { action: 'node-usage' } as never, {} as never);
|
||||
|
||||
expect(result.workflowsInScope).toBe(10);
|
||||
expect(result.nodeTypes).toEqual([
|
||||
{ nodeType: '@n8n/n8n-nodes-langchain.lmChatAnthropic', workflowCount: 10 },
|
||||
]);
|
||||
// An absence is evidence, not a gap — and the note has to say so, or a type the project
|
||||
// never uses reads as "unknown" rather than "not chosen".
|
||||
expect(result.note).toContain('absent from this list is used by no workflow in scope');
|
||||
expect(result.note).toContain('Node types only');
|
||||
});
|
||||
|
||||
// The claim above is only true of a complete list. On a cut one it is actively wrong, so
|
||||
// the note has to withdraw it rather than repeat it.
|
||||
it('withdraws the absence claim when the list is cut', async () => {
|
||||
const context = withNodeUsage({
|
||||
workflowsInScope: 40,
|
||||
nodeTypes: [{ nodeType: 'n8n-nodes-base.slack', workflowCount: 9 }],
|
||||
truncated: true,
|
||||
});
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
|
||||
const result = await executeTool<{ truncated?: boolean; note: string }>(
|
||||
tool,
|
||||
{ action: 'node-usage', limit: 1 } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.note).toContain('do not read an absence as evidence');
|
||||
expect(result.note).not.toContain('used by no workflow in scope');
|
||||
});
|
||||
|
||||
it('passes the limit through to the service', async () => {
|
||||
const context = withNodeUsage();
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
|
||||
await executeTool(tool, { action: 'node-usage', limit: 5 } as never, {} as never);
|
||||
|
||||
expect(context.workflowService.nodeUsage).toHaveBeenCalledWith({ limit: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflows for a node type', () => {
|
||||
it('names the workflows using a type and reports truncation', async () => {
|
||||
const context = withNodeUsage({
|
||||
workflowsInScope: 12,
|
||||
workflows: [
|
||||
{ workflowId: 'wf-1', name: 'Daily sync', updatedAt: '2026-01-01T00:00:00.000Z' },
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
|
||||
const result = await executeTool(
|
||||
tool,
|
||||
{ action: 'node-usage', nodeType: 'n8n-nodes-base.slack', limit: 1 } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(context.workflowService.nodeUsage).toHaveBeenCalledWith({
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
limit: 1,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
workflowsInScope: 12,
|
||||
workflows: [
|
||||
{ workflowId: 'wf-1', name: 'Daily sync', updatedAt: '2026-01-01T00:00:00.000Z' },
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('passes nodeTypes through to the service', async () => {
|
||||
const context = withNodeUsage();
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
|
||||
await executeTool(
|
||||
tool,
|
||||
{ action: 'list', nodeTypes: ['n8n-nodes-base.slack'] } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(context.workflowService.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nodeTypes: ['n8n-nodes-base.slack'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,10 +79,19 @@ import { nodeGroupDroppedWarnings } from './workflows/workflow-validation-warnin
|
|||
|
||||
// ── Action schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
// `list` and `setup` share this field, and the schema sanitizer requires one
|
||||
// description per shared field, so it has to cover both uses.
|
||||
// `list`, `node-usage` and `setup` share these fields, and the schema sanitizer rejects
|
||||
// conflicting descriptions for one field name across the union, so each has to read correctly
|
||||
// for every action that uses it.
|
||||
const PROJECT_ID_FIELD_DESCRIPTION =
|
||||
'Project ID, obtainable from `workspace(action="list-projects")`. For `list`: read that one project instead of the default scope — use it for "what is in project X" rather than listing the whole instance and guessing which results belong to X. Read-only, so it narrows what you can already see rather than widening it, and it does not move where you can write. For `setup`: scope credential creation to that project.';
|
||||
'Project ID, obtainable from `workspace(action="list-projects")`. For `list` and `node-usage`: read that one project instead of the default scope — use it for "what is in project X" rather than reading the whole instance and guessing which results belong to X. Read-only, so it narrows what you can already see rather than widening it, and it does not move where you can write. For `setup`: scope credential creation to that project.';
|
||||
|
||||
const SCOPE_FIELD_DESCRIPTION =
|
||||
"Which project(s) to read. Defaults to this conversation's project. Use 'instance' only when you have a clear reason to look across all projects you can access.";
|
||||
|
||||
const LIMIT_FIELD_DESCRIPTION = 'Max results to return';
|
||||
|
||||
const NODE_TYPES_FIELD_DESCRIPTION =
|
||||
'Full node types, e.g. ["n8n-nodes-base.slack"]. Matched against the nodes a workflow actually contains, from an index — so it finds users of a node however the workflow is named, and costs one call however many workflows exist. Keeps only workflows containing at least one of these.';
|
||||
|
||||
const listAction = z.object({
|
||||
action: z
|
||||
|
|
@ -96,19 +105,51 @@ const listAction = z.object({
|
|||
.describe(
|
||||
'Substring filter on the workflow NAME only — it does not match node types, descriptions, or what a workflow does. Omit it whenever you need the actual inventory (what exists here, project status, what to do next): a name-filtered list is not the set of workflows in scope. Use it only when the user named a workflow, or to locate one you already know exists.',
|
||||
),
|
||||
limit: z.number().int().positive().max(100).optional().describe('Max results to return'),
|
||||
nodeTypes: z.array(z.string()).optional().describe(NODE_TYPES_FIELD_DESCRIPTION),
|
||||
limit: z.number().int().positive().max(100).optional().describe(LIMIT_FIELD_DESCRIPTION),
|
||||
status: z
|
||||
.enum(['active', 'archived', 'all'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Which workflows to list. Defaults to active; use archived to find workflows that can be restored.',
|
||||
),
|
||||
scope: z
|
||||
.enum(['project', 'instance'])
|
||||
scope: z.enum(['project', 'instance']).optional().describe(SCOPE_FIELD_DESCRIPTION),
|
||||
projectId: z.string().optional().describe(PROJECT_ID_FIELD_DESCRIPTION),
|
||||
});
|
||||
|
||||
/** `list` as it looks without the dependency index behind it — the shape the agent saw before
|
||||
* node usage existed. Registered instead of `listAction` when the capability is off, so the
|
||||
* off arm really lacks the field rather than being told to avoid it. `Input` still derives
|
||||
* from the full `listAction`, so the handler keeps its types either way. */
|
||||
const listActionWithoutNodeTypes = listAction.omit({ nodeTypes: true });
|
||||
|
||||
/**
|
||||
* The cheap rung of preference discovery. `list` filters on the workflow name only, so learning
|
||||
* what a project is built out of otherwise means fetching every workflow and reading its nodes:
|
||||
* measured across ten workflows, that is ~6,500 tokens against ~85 for this aggregate.
|
||||
*/
|
||||
const nodeUsageAction = z.object({
|
||||
action: z
|
||||
.literal('node-usage')
|
||||
.describe(
|
||||
'Which node types the workflows in scope actually use, and how many use each, most-used ' +
|
||||
'first. Read this BEFORE opening workflows when the question is what a project is built ' +
|
||||
'out of — its conventions, which integrations are in play, whether something already ' +
|
||||
'exists. Call it with no `nodeType` for the overview; call it with one to get the ' +
|
||||
'workflows using that type, most recently updated first, when you want to read a ' +
|
||||
'current example.',
|
||||
),
|
||||
nodeType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Which project(s) to search. Defaults to this conversation's project. Use 'instance' only when you have a clear reason to look across all projects you can access.",
|
||||
'A single full node type, e.g. "@n8n/n8n-nodes-langchain.lmChatAnthropic". Omit it for the ' +
|
||||
'overview of every type in use.',
|
||||
),
|
||||
// Caps both shapes: node types in the overview, workflows when a `nodeType` is given. The
|
||||
// ceiling is above the overview's default so raising it after a truncated answer works.
|
||||
limit: z.number().int().positive().max(200).optional().describe(LIMIT_FIELD_DESCRIPTION),
|
||||
scope: z.enum(['project', 'instance']).optional().describe(SCOPE_FIELD_DESCRIPTION),
|
||||
projectId: z.string().optional().describe(PROJECT_ID_FIELD_DESCRIPTION),
|
||||
});
|
||||
|
||||
|
|
@ -323,6 +364,7 @@ interface WorkflowToolContext {
|
|||
// regardless of which dynamic subset the schema actually includes.
|
||||
type Input =
|
||||
| z.infer<typeof listAction>
|
||||
| z.infer<typeof nodeUsageAction>
|
||||
| z.infer<typeof getAction>
|
||||
| z.infer<typeof getJsonAction>
|
||||
| z.infer<typeof getAsCodeAction>
|
||||
|
|
@ -344,6 +386,7 @@ type PublishRollbackResult = {
|
|||
};
|
||||
export type WorkflowAction =
|
||||
| 'list'
|
||||
| 'node-usage'
|
||||
| 'get'
|
||||
| 'get-json'
|
||||
| 'get-as-code'
|
||||
|
|
@ -371,6 +414,9 @@ type WorkflowsToolOptionsInput = WorkflowsToolOptions | 'full' | 'orchestrator';
|
|||
|
||||
const WORKFLOW_ACTION_ORDER = [
|
||||
'list',
|
||||
// Directly after `list`: it answers the same discovery question far more cheaply, and this
|
||||
// ordering is what the agent reads first in the tool schema.
|
||||
'node-usage',
|
||||
'get',
|
||||
'get-json',
|
||||
'get-as-code',
|
||||
|
|
@ -388,6 +434,7 @@ const WORKFLOW_ACTION_ORDER = [
|
|||
|
||||
const WORKFLOW_ACTION_LABELS = {
|
||||
list: 'list',
|
||||
'node-usage': 'summarize which node types are in use',
|
||||
get: 'inspect',
|
||||
'get-json': 'inspect full WorkflowJSON',
|
||||
'get-as-code': 'convert existing workflows to TypeScript SDK code',
|
||||
|
|
@ -413,9 +460,14 @@ function getSupportedWorkflowActionSchemas(
|
|||
): Partial<Record<WorkflowAction, WorkflowActionSchema>> {
|
||||
const hasNamedVersions = !!context.workflowService.updateVersion;
|
||||
const hasVersions = !!context.workflowService.listVersions;
|
||||
// One gate for both halves of the surface: the host attaches `nodeUsage` only when the
|
||||
// dependency index is wired and the capability is on, so the agent is never offered an
|
||||
// action or a filter it would get an error from.
|
||||
const hasNodeUsage = !!context.workflowService.nodeUsage;
|
||||
|
||||
return {
|
||||
list: listAction,
|
||||
list: hasNodeUsage ? listAction : listActionWithoutNodeTypes,
|
||||
...(hasNodeUsage ? { 'node-usage': nodeUsageAction } : {}),
|
||||
get: getAction,
|
||||
...(surface !== 'orchestrator' ? { 'get-json': getJsonAction } : {}),
|
||||
'get-as-code': getAsCodeAction,
|
||||
|
|
@ -486,6 +538,57 @@ async function resolveWorkflowName(
|
|||
.catch(() => workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the counts against the scope total, because "10 of 10" is the statement a preference is
|
||||
* made of and a bare count says nothing without its denominator.
|
||||
*/
|
||||
async function handleNodeUsage(
|
||||
context: InstanceAiContext,
|
||||
input: Extract<Input, { action: 'node-usage' }>,
|
||||
) {
|
||||
if (!context.workflowService.nodeUsage) {
|
||||
return {
|
||||
note: 'Node usage is not available on this instance. Read the workflows you need directly.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await context.workflowService.nodeUsage({
|
||||
...(input.nodeType ? { nodeType: input.nodeType } : {}),
|
||||
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
||||
...(input.scope ? { scope: input.scope } : {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
});
|
||||
|
||||
if (input.nodeType) {
|
||||
return {
|
||||
nodeType: input.nodeType,
|
||||
workflowsInScope: result.workflowsInScope,
|
||||
workflows: result.workflows ?? [],
|
||||
...(result.truncated ? { truncated: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// What an absence means depends on whether the list is complete. On a full list, a missing
|
||||
// type is a choice the user has not made, and saying so is most of the value. On a cut list
|
||||
// it means nothing at all, and the note must withdraw the claim rather than repeat it.
|
||||
const absence = result.truncated
|
||||
? 'This list is CUT at the top ' +
|
||||
`${result.nodeTypes?.length ?? 0} most-used types — a type missing from it may still be ` +
|
||||
'in use, so do not read an absence as evidence. Raise `limit`, or narrow with `projectId`.'
|
||||
: 'A type absent from this list is used by no workflow in scope.';
|
||||
|
||||
return {
|
||||
workflowsInScope: result.workflowsInScope,
|
||||
nodeTypes: result.nodeTypes ?? [],
|
||||
...(result.truncated ? { truncated: true } : {}),
|
||||
// The limit of the surface is named so counts are never quoted as parameter-level house style.
|
||||
note:
|
||||
`Counts are how many workflows use each type, out of workflowsInScope. ${absence} ` +
|
||||
'Node types only — for parameter-level convention (retry settings, naming, model ' +
|
||||
'options), read one workflow with `get`.',
|
||||
};
|
||||
}
|
||||
|
||||
async function handleList(context: InstanceAiContext, input: Extract<Input, { action: 'list' }>) {
|
||||
const { workflows, total, totalInScope } = await context.workflowService.list({
|
||||
limit: input.limit,
|
||||
|
|
@ -493,6 +596,7 @@ async function handleList(context: InstanceAiContext, input: Extract<Input, { ac
|
|||
...(input.status ? { status: input.status } : {}),
|
||||
...(input.scope ? { scope: input.scope } : {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.nodeTypes?.length ? { nodeTypes: input.nodeTypes } : {}),
|
||||
});
|
||||
|
||||
// A partial list must never read as the complete inventory: guessed name
|
||||
|
|
@ -2014,6 +2118,8 @@ export function createWorkflowsTool(
|
|||
switch (workflowInput.action) {
|
||||
case 'list':
|
||||
return await handleList(context, workflowInput);
|
||||
case 'node-usage':
|
||||
return await handleNodeUsage(context, workflowInput);
|
||||
case 'get':
|
||||
return await handleGet(context, workflowInput);
|
||||
case 'get-json':
|
||||
|
|
|
|||
|
|
@ -310,6 +310,22 @@ export interface WorkflowListResult {
|
|||
totalInScope: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the workflows in scope are built out of. The cheap rung of preference discovery:
|
||||
* `nodeTypes` answers "what does this project reach for" without opening a workflow, and
|
||||
* `workflows` names the ones using a given type so one read gets the current house style.
|
||||
*/
|
||||
export interface NodeUsageResult {
|
||||
/** Indexed, non-archived workflows in scope — the denominator for every count. */
|
||||
workflowsInScope: number;
|
||||
/** Set when no `nodeType` was asked for: every node type in use, most-used first. */
|
||||
nodeTypes?: Array<{ nodeType: string; workflowCount: number }>;
|
||||
/** Set when a `nodeType` was asked for: the workflows using it, most recently updated first. */
|
||||
workflows?: Array<{ workflowId: string; name: string; updatedAt: string }>;
|
||||
/** True when the limit cut the list short, so a partial answer is never read as the whole. */
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface InstanceAiWorkflowService {
|
||||
list(options?: {
|
||||
query?: string;
|
||||
|
|
@ -323,7 +339,27 @@ export interface InstanceAiWorkflowService {
|
|||
* access. Writes stay locked to the thread's bound project regardless.
|
||||
*/
|
||||
projectId?: string;
|
||||
/**
|
||||
* Keep only workflows containing at least one node of these types
|
||||
* (`n8n-nodes-base.slack`). Resolved from the dependency index, so it matches what a
|
||||
* workflow actually contains rather than what its name suggests, and costs one join
|
||||
* instead of a fetch per workflow.
|
||||
*/
|
||||
nodeTypes?: string[];
|
||||
}): Promise<WorkflowListResult>;
|
||||
/**
|
||||
* Node-type usage across the workflows in scope, read from the dependency index rather than by
|
||||
* fetching workflows. Without `nodeType` it returns the histogram; with one, the workflows using
|
||||
* it. Node types only — parameter-level house style still needs a `get`.
|
||||
*
|
||||
* Optional: present only where the host wires the index behind it.
|
||||
*/
|
||||
nodeUsage?(options?: {
|
||||
nodeType?: string;
|
||||
limit?: number;
|
||||
scope?: 'project' | 'instance';
|
||||
projectId?: string;
|
||||
}): Promise<NodeUsageResult>;
|
||||
get(workflowId: string): Promise<WorkflowDetail>;
|
||||
/** Get the workflow as the SDK's WorkflowJSON (full node data for generateWorkflowCode).
|
||||
* Pass a versionId to get a past version's graph instead of the current draft. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
// The barrel is mocked so this file exercises the adapter's own wiring rather than pulling the
|
||||
// whole instance-ai package in behind it, matching the other adapter tests.
|
||||
vi.mock('@n8n/instance-ai', async () => {
|
||||
const { WorkflowSaveConflictError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error.js'
|
||||
);
|
||||
const { WorkflowNotFoundError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error.js'
|
||||
);
|
||||
const { WorkflowEditorLockedError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-editor-locked.error.js'
|
||||
);
|
||||
return {
|
||||
WorkflowSaveConflictError,
|
||||
WorkflowNotFoundError,
|
||||
WorkflowEditorLockedError,
|
||||
wrapUntrustedData: (content: string) => content,
|
||||
builderTemplatesOptionsFromEnv: () => ({}),
|
||||
deriveCredentialHosts: vi.fn().mockReturnValue([]),
|
||||
BuilderTemplatesService: class {
|
||||
async getBundle() {
|
||||
return { files: [], indexTxt: '', version: null };
|
||||
}
|
||||
getVersion() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@n8n/ai-utilities', () => ({
|
||||
braveSearch: vi.fn(),
|
||||
searxngSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { INSTANCE_AI_NODE_USAGE_FLAG } from '@n8n/api-types';
|
||||
import type { OutboundHttp } from '@n8n/backend-network';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import type {
|
||||
AiBuilderTemporaryWorkflowRepository,
|
||||
ExecutionRepository,
|
||||
ProjectRepository,
|
||||
SharedWorkflowRepository,
|
||||
User,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { GLOBAL_MEMBER_ROLE } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { ActiveExecutions } from '@/active-executions';
|
||||
import type { CollaborationService } from '@/collaboration/collaboration.service';
|
||||
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import type { License } from '@/license';
|
||||
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import type { DataTableRepository } from '@/modules/data-table/data-table.repository';
|
||||
import type { DataTableService } from '@/modules/data-table/data-table.service';
|
||||
import type { WorkflowDependencyQueryService } from '@/modules/workflow-index/workflow-dependency-query.service';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PostHogClient } from '@/posthog';
|
||||
import type { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
|
||||
import type { FolderService } from '@/services/folder.service';
|
||||
import type { InstanceWriteAccessService } from '@/services/instance-write-access.service';
|
||||
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
import type { RoleService } from '@/services/role.service';
|
||||
import type { TagService } from '@/services/tag.service';
|
||||
import type { Telemetry } from '@/telemetry';
|
||||
import type { WorkflowRunner } from '@/workflow-runner';
|
||||
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||
import type { WorkflowService } from '@/workflows/workflow.service';
|
||||
import type { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
||||
|
||||
import type { InstanceAiSettingsService } from '../instance-ai-settings.service';
|
||||
import { InstanceAiAdapterService } from '../instance-ai.adapter.service';
|
||||
import type { WorkflowTemplatesService } from '../workflow-templates.service';
|
||||
|
||||
const user = mock<User>({ id: 'user-1', role: GLOBAL_MEMBER_ROLE });
|
||||
|
||||
const workflowService = mock<WorkflowService>();
|
||||
|
||||
function buildAdapter(options: { dependencyQueryService?: WorkflowDependencyQueryService }) {
|
||||
const logger = mock<Logger>();
|
||||
const globalConfig = mock<GlobalConfig>({ ai: { allowSendingParameterValues: true } });
|
||||
|
||||
return new InstanceAiAdapterService(
|
||||
logger,
|
||||
globalConfig,
|
||||
workflowService,
|
||||
mock<WorkflowFinderService>(),
|
||||
mock<WorkflowRepository>(),
|
||||
mock<SharedWorkflowRepository>(),
|
||||
mock<ProjectRepository>(),
|
||||
mock<ExecutionRepository>(),
|
||||
mock<CredentialsService>(),
|
||||
mock<CredentialsFinderService>(),
|
||||
mock<ActiveExecutions>(),
|
||||
mock<WorkflowRunner>(),
|
||||
mock<LoadNodesAndCredentials>(),
|
||||
mock<NodeTypes>(),
|
||||
mock<InstanceSettings>({ staticCacheDir: '/tmp/test-cache', n8nFolder: '/tmp/test-cache' }),
|
||||
mock<DataTableService>(),
|
||||
mock<DataTableRepository>(),
|
||||
new NodeResourceExplorerService(
|
||||
logger,
|
||||
mock<DynamicNodeParametersService>(),
|
||||
mock<CredentialsFinderService>(),
|
||||
mock<ProjectRepository>(),
|
||||
mock<NodeTypes>(),
|
||||
),
|
||||
mock<FolderService>(),
|
||||
mock<ProjectService>(),
|
||||
mock<TagService>(),
|
||||
mock<InstanceWriteAccessService>(),
|
||||
mock<InstanceAiSettingsService>(),
|
||||
mock<WorkflowHistoryService>(),
|
||||
mock<EnterpriseWorkflowService>(),
|
||||
mock<License>(),
|
||||
mock<ExecutionPersistence>(),
|
||||
mock<EventService>(),
|
||||
mock<RoleService>(),
|
||||
mock<Telemetry>(),
|
||||
mock<AiBuilderTemporaryWorkflowRepository>(),
|
||||
mock<OutboundHttp>(),
|
||||
mock<AiGatewayService>(),
|
||||
mock<WorkflowTemplatesService>(),
|
||||
mock<CollaborationService>(),
|
||||
mock<PolicyEnforcementService>(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
options.dependencyQueryService,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(Container, 'get').mockReturnValue(mock<ExecutionPersistence>());
|
||||
});
|
||||
|
||||
describe('InstanceAiAdapterService node usage', () => {
|
||||
describe('isNodeUsageEnabled()', () => {
|
||||
const gateFor = async (flags: Record<string, unknown> | Error) => {
|
||||
const postHogClient = mock<PostHogClient>();
|
||||
if (flags instanceof Error) {
|
||||
postHogClient.getFeatureFlags.mockRejectedValue(flags);
|
||||
} else {
|
||||
postHogClient.getFeatureFlags.mockResolvedValue(flags as never);
|
||||
}
|
||||
vi.spyOn(Container, 'get').mockImplementation(((token: unknown) =>
|
||||
token === PostHogClient ? postHogClient : mock<ExecutionPersistence>()) as never);
|
||||
return await buildAdapter({}).isNodeUsageEnabled(user);
|
||||
};
|
||||
|
||||
it('is on for a user in the rollout', async () => {
|
||||
expect(await gateFor({ [INSTANCE_AI_NODE_USAGE_FLAG]: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('is off when the flag is absent', async () => {
|
||||
expect(await gateFor({})).toBe(false);
|
||||
});
|
||||
|
||||
it('is off when the flag is explicitly false', async () => {
|
||||
expect(await gateFor({ [INSTANCE_AI_NODE_USAGE_FLAG]: false })).toBe(false);
|
||||
});
|
||||
|
||||
// A flag-plane outage must not switch a context surface on by accident.
|
||||
it('fails closed when PostHog is unreachable', async () => {
|
||||
expect(await gateFor(new Error('PostHog unreachable'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capability gate', () => {
|
||||
// The tool registers the action from the method's presence, so absence here is what actually
|
||||
// removes the surface from the agent — not a check further down.
|
||||
// Default-off is the point of the rollout gate: a context created without an explicit
|
||||
// decision must not carry the surface.
|
||||
it('omits nodeUsage when the caller passes no gate decision', () => {
|
||||
const service = buildAdapter({
|
||||
dependencyQueryService: mock<WorkflowDependencyQueryService>(),
|
||||
});
|
||||
|
||||
expect(service.createContext(user).workflowService.nodeUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits nodeUsage when the rollout gate is closed', () => {
|
||||
const service = buildAdapter({
|
||||
dependencyQueryService: mock<WorkflowDependencyQueryService>(),
|
||||
});
|
||||
|
||||
const context = service.createContext(user, { nodeUsageEnabled: false });
|
||||
|
||||
expect(context.workflowService.nodeUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits nodeUsage when the dependency index is not wired', () => {
|
||||
const service = buildAdapter({});
|
||||
|
||||
expect(
|
||||
service.createContext(user, { nodeUsageEnabled: true }).workflowService.nodeUsage,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes nodeUsage when the gate is open and the index is wired', () => {
|
||||
const service = buildAdapter({
|
||||
dependencyQueryService: mock<WorkflowDependencyQueryService>(),
|
||||
});
|
||||
|
||||
expect(
|
||||
service.createContext(user, { nodeUsageEnabled: true }).workflowService.nodeUsage,
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeUsage()', () => {
|
||||
it("defaults to the thread's bound project", async () => {
|
||||
const dependencyQueryService = mock<WorkflowDependencyQueryService>();
|
||||
dependencyQueryService.getNodeTypeUsage.mockResolvedValue({
|
||||
workflowsInScope: 3,
|
||||
nodeTypes: [{ nodeType: 'n8n-nodes-base.slack', workflowCount: 2 }],
|
||||
});
|
||||
const service = buildAdapter({ dependencyQueryService });
|
||||
|
||||
const context = service.createContext(user, {
|
||||
projectId: 'bound-project',
|
||||
nodeUsageEnabled: true,
|
||||
});
|
||||
const result = await context.workflowService.nodeUsage?.();
|
||||
|
||||
expect(dependencyQueryService.getNodeTypeUsage).toHaveBeenCalledWith(user, {
|
||||
projectId: 'bound-project',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
workflowsInScope: 3,
|
||||
nodeTypes: [{ nodeType: 'n8n-nodes-base.slack', workflowCount: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the project filter when the caller widens to the instance', async () => {
|
||||
const dependencyQueryService = mock<WorkflowDependencyQueryService>();
|
||||
dependencyQueryService.getNodeTypeUsage.mockResolvedValue({
|
||||
workflowsInScope: 0,
|
||||
nodeTypes: [],
|
||||
});
|
||||
const service = buildAdapter({ dependencyQueryService });
|
||||
|
||||
const context = service.createContext(user, {
|
||||
projectId: 'bound-project',
|
||||
nodeUsageEnabled: true,
|
||||
});
|
||||
await context.workflowService.nodeUsage?.({ scope: 'instance' });
|
||||
|
||||
expect(dependencyQueryService.getNodeTypeUsage).toHaveBeenCalledWith(user, {});
|
||||
});
|
||||
|
||||
it('serialises workflow timestamps for the agent', async () => {
|
||||
const dependencyQueryService = mock<WorkflowDependencyQueryService>();
|
||||
dependencyQueryService.getNodeTypeUsage.mockResolvedValue({
|
||||
workflowsInScope: 5,
|
||||
workflows: [
|
||||
{
|
||||
workflowId: 'wf-1',
|
||||
name: 'Daily sync',
|
||||
updatedAt: new Date('2026-01-02T03:04:05.000Z'),
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
const service = buildAdapter({ dependencyQueryService });
|
||||
|
||||
const context = service.createContext(user, { nodeUsageEnabled: true });
|
||||
const result = await context.workflowService.nodeUsage?.({
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
workflowsInScope: 5,
|
||||
workflows: [
|
||||
{ workflowId: 'wf-1', name: 'Daily sync', updatedAt: '2026-01-02T03:04:05.000Z' },
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('list()', () => {
|
||||
it('passes nodeTypes into the scope filter so totalInScope describes the same set', async () => {
|
||||
workflowService.getMany.mockResolvedValue({ workflows: [], count: 0 });
|
||||
const service = buildAdapter({
|
||||
dependencyQueryService: mock<WorkflowDependencyQueryService>(),
|
||||
});
|
||||
|
||||
const context = service.createContext(user, {
|
||||
projectId: 'bound-project',
|
||||
nodeUsageEnabled: true,
|
||||
});
|
||||
await context.workflowService.list({ nodeTypes: ['n8n-nodes-base.slack'], query: 'sync' });
|
||||
|
||||
// Both reads carry the node-type filter: the second exists to say how many the *name*
|
||||
// filter hid, which would misreport if the two scopes differed.
|
||||
expect(workflowService.getMany).toHaveBeenNthCalledWith(1, user, {
|
||||
take: 50,
|
||||
filter: {
|
||||
isArchived: false,
|
||||
projectId: 'bound-project',
|
||||
nodeTypes: ['n8n-nodes-base.slack'],
|
||||
query: 'sync',
|
||||
},
|
||||
});
|
||||
expect(workflowService.getMany).toHaveBeenNthCalledWith(2, user, {
|
||||
take: 1,
|
||||
filter: {
|
||||
isArchived: false,
|
||||
projectId: 'bound-project',
|
||||
nodeTypes: ['n8n-nodes-base.slack'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores nodeTypes when the capability is off', async () => {
|
||||
workflowService.getMany.mockResolvedValue({ workflows: [], count: 0 });
|
||||
const service = buildAdapter({});
|
||||
|
||||
const context = service.createContext(user, { projectId: 'bound-project' });
|
||||
await context.workflowService.list({ nodeTypes: ['n8n-nodes-base.slack'] });
|
||||
|
||||
expect(workflowService.getMany).toHaveBeenCalledWith(user, {
|
||||
take: 50,
|
||||
filter: { isArchived: false, projectId: 'bound-project' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -102,6 +102,9 @@ function globalConfigStub(
|
|||
return {
|
||||
ai: { allowSendingParameterValues: overrides.allowSendingParameterValues ?? false },
|
||||
executions: { mode: overrides.queueMode ? 'queue' : 'regular' },
|
||||
// Node usage is gated on the dependency index being wired too, which these tests do not
|
||||
// pass, so the value here only has to exist. See instance-ai.adapter.node-usage.test.ts.
|
||||
instanceAi: { nodeUsageEnabled: false },
|
||||
} as unknown as ConstructorParameters<typeof InstanceAiAdapterService>[1];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
CONFIG_EVALUATIONS_FLAG,
|
||||
CONFIG_EVALUATIONS_ENABLED_VARIANT,
|
||||
INSTANCE_AI_MCP_CONNECTIONS_FLAG,
|
||||
INSTANCE_AI_NODE_USAGE_FLAG,
|
||||
TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE,
|
||||
upsertEvaluationConfigSchema,
|
||||
INSTANCE_AI_MCP_CONNECTIONS_ENABLED_VARIANT,
|
||||
|
|
@ -38,6 +39,7 @@ import type {
|
|||
DataTableSummary,
|
||||
DataTableColumnInfo,
|
||||
WorkflowSummary,
|
||||
NodeUsageResult,
|
||||
WorkflowDetail,
|
||||
WorkflowNode,
|
||||
WorkflowVersionSummary,
|
||||
|
|
@ -139,6 +141,7 @@ import { DataTableService } from '@/modules/data-table/data-table.service';
|
|||
import { MCP_REGISTRY_PACKAGE_NAME } from '@/modules/mcp-registry/node-description-transform';
|
||||
import type { McpRegistrySearchResult } from '@/modules/mcp-registry/registry/mcp-registry-search';
|
||||
import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry.service';
|
||||
import { WorkflowDependencyQueryService } from '@/modules/workflow-index/workflow-dependency-query.service';
|
||||
import { NodeCatalogService } from '@/node-catalog';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { userHasScopes } from '@/permissions.ee/check-access';
|
||||
|
|
@ -337,6 +340,9 @@ export class InstanceAiAdapterService {
|
|||
// DI (by type, not position) always provides it in a running instance.
|
||||
private readonly evaluationConfigService?: EvaluationConfigService,
|
||||
private readonly llmJudgeProviderRegistry?: LlmJudgeProviderRegistry,
|
||||
// Appended rather than grouped with the other query services: existing tests construct this
|
||||
// service positionally, so inserting mid-list renames every later argument.
|
||||
private readonly workflowDependencyQueryService?: WorkflowDependencyQueryService,
|
||||
) {
|
||||
this.logger = logger.scoped('instance-ai');
|
||||
this.allowSendingParameterValues = globalConfig.ai.allowSendingParameterValues;
|
||||
|
|
@ -367,6 +373,9 @@ export class InstanceAiAdapterService {
|
|||
/** Per-user MCP registry gate (via `isMcpConnectionsEnabled`). Falsy →
|
||||
* mcp service/tool not wired. */
|
||||
mcpConnectionsEnabled?: boolean;
|
||||
/** Per-user node-usage gate (via `isNodeUsageEnabled`). Falsy → neither the
|
||||
* `node-usage` action nor the `nodeTypes` filter on `list` is offered. */
|
||||
nodeUsageEnabled?: boolean;
|
||||
/** Host-resolved model for the run — fallback for utility LLM calls
|
||||
* (simulation fixtures, destructiveness classification). */
|
||||
modelId?: ModelConfig;
|
||||
|
|
@ -382,6 +391,7 @@ export class InstanceAiAdapterService {
|
|||
agentId,
|
||||
configEvalsEnabled,
|
||||
mcpConnectionsEnabled,
|
||||
nodeUsageEnabled,
|
||||
modelId,
|
||||
} = options ?? {};
|
||||
|
||||
|
|
@ -400,7 +410,7 @@ export class InstanceAiAdapterService {
|
|||
userId: user.id,
|
||||
projectId,
|
||||
modelId,
|
||||
workflowService: this.createWorkflowAdapter(user, threadId, projectId),
|
||||
workflowService: this.createWorkflowAdapter(user, threadId, projectId, nodeUsageEnabled),
|
||||
executionService: this.createExecutionAdapter(user, pushRef, threadId),
|
||||
credentialService,
|
||||
nodeService: this.createNodeAdapter(user),
|
||||
|
|
@ -485,6 +495,21 @@ export class InstanceAiAdapterService {
|
|||
return flags?.[CONFIG_EVALUATIONS_FLAG] === CONFIG_EVALUATIONS_ENABLED_VARIANT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for the node-usage context surface. PostHog owns cohort rollout;
|
||||
* `N8N_INSTANCE_AI_NODE_USAGE_ENABLED` force-enables because {@link PostHogClient}
|
||||
* layers that override on top of the resolved flags. Fails closed on any PostHog
|
||||
* error, so an outage never switches a context surface on by accident.
|
||||
*/
|
||||
async isNodeUsageEnabled(user: User): Promise<boolean> {
|
||||
try {
|
||||
const flags = await Container.get(PostHogClient).getFeatureFlags(user);
|
||||
return flags?.[INSTANCE_AI_NODE_USAGE_FLAG] === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Gate for MCP registry discovery tool. All three must hold:
|
||||
* 1. the `mcp-registry` module is active
|
||||
* 2. the admin allows MCP access instance-wide
|
||||
|
|
@ -649,6 +674,7 @@ export class InstanceAiAdapterService {
|
|||
user: User,
|
||||
threadId?: string,
|
||||
boundProjectId?: string,
|
||||
nodeUsageGateOpen = false,
|
||||
): InstanceAiWorkflowService {
|
||||
const {
|
||||
workflowService,
|
||||
|
|
@ -665,9 +691,23 @@ export class InstanceAiAdapterService {
|
|||
telemetry,
|
||||
collaborationService,
|
||||
policyEnforcementService,
|
||||
workflowDependencyQueryService,
|
||||
} = this;
|
||||
const logger = this.logger;
|
||||
const assertNotReadOnly = () => this.assertInstanceNotReadOnly('workflows');
|
||||
// Resolved once per context, upstream in `createContext`: the tool registers the action from
|
||||
// the method's presence, so nothing downstream has to know a rollout flag exists.
|
||||
const nodeUsageEnabled = nodeUsageGateOpen && workflowDependencyQueryService !== undefined;
|
||||
|
||||
/**
|
||||
* Which project a read targets. An explicit `projectId` wins, otherwise the thread's own
|
||||
* project unless the caller widened to the whole instance. Shared by `list` and `nodeUsage`
|
||||
* so the two never disagree about what "this project" means.
|
||||
*/
|
||||
const resolveTargetProjectId = (options?: {
|
||||
projectId?: string;
|
||||
scope?: 'project' | 'instance';
|
||||
}) => options?.projectId ?? (options?.scope !== 'instance' ? boundProjectId : undefined);
|
||||
const { resolveBoundProjectId } = this.createProjectScopeHelpers(user, boundProjectId);
|
||||
const redactParameters = !allowSendingParameterValues;
|
||||
|
||||
|
|
@ -700,18 +740,49 @@ export class InstanceAiAdapterService {
|
|||
};
|
||||
|
||||
return {
|
||||
// Present only when the index is wired and the flag is on; the tool reads that presence
|
||||
// to decide whether the action exists at all.
|
||||
...(nodeUsageEnabled && workflowDependencyQueryService
|
||||
? {
|
||||
async nodeUsage(options): Promise<NodeUsageResult> {
|
||||
const targetProjectId = resolveTargetProjectId(options);
|
||||
const result = await workflowDependencyQueryService.getNodeTypeUsage(user, {
|
||||
...(targetProjectId ? { projectId: targetProjectId } : {}),
|
||||
...(options?.nodeType ? { nodeType: options.nodeType } : {}),
|
||||
...(options?.limit !== undefined ? { limit: options.limit } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
workflowsInScope: result.workflowsInScope,
|
||||
...(result.nodeTypes ? { nodeTypes: result.nodeTypes } : {}),
|
||||
...(result.workflows
|
||||
? {
|
||||
workflows: result.workflows.map((workflow) => ({
|
||||
...workflow,
|
||||
updatedAt: workflow.updatedAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(result.truncated ? { truncated: true } : {}),
|
||||
};
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
async list(options) {
|
||||
// An explicit projectId targets one project; otherwise the thread's own
|
||||
// project unless the caller widened to the whole instance. Either way it
|
||||
// goes in as a *filter* on `getMany`, which resolves readability from the
|
||||
// user's own project/workflow roles — so this can only narrow the set the
|
||||
// The target project goes in as a *filter* on `getMany`, which resolves readability
|
||||
// from the user's own project/workflow roles — so this can only narrow the set the
|
||||
// caller could already read, never widen it. Writes keep using
|
||||
// `resolveBoundProjectId` and stay locked to the bound project.
|
||||
const targetProjectId =
|
||||
options?.projectId ?? (options?.scope !== 'instance' ? boundProjectId : undefined);
|
||||
const targetProjectId = resolveTargetProjectId(options);
|
||||
const scopeFilter = {
|
||||
...(options?.status === 'all' ? {} : { isArchived: options?.status === 'archived' }),
|
||||
...(targetProjectId ? { projectId: targetProjectId } : {}),
|
||||
// Part of the scope, not of the name filter: `totalInScope` has to describe the
|
||||
// same node-type-filtered set, or the "hidden workflows" note misreports.
|
||||
...(nodeUsageEnabled && options?.nodeTypes?.length
|
||||
? { nodeTypes: options.nodeTypes }
|
||||
: {}),
|
||||
};
|
||||
const filter = {
|
||||
...scopeFilter,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,32 @@ import { DataTableRepository } from '@/modules/data-table/data-table.repository'
|
|||
import { RoleService } from '@/services/role.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
/** Workflows named for a node type when the caller does not say how many it wants. */
|
||||
const DEFAULT_NODE_USAGE_WORKFLOW_LIMIT = 10;
|
||||
|
||||
/** Node types in the histogram when the caller does not say how many it wants. Higher than the
|
||||
* workflow limit because the histogram is the answer rather than a sample of it, and a project
|
||||
* rarely reaches for more types than this — but it stays bounded, because an instance-wide read
|
||||
* otherwise returns every type in use. */
|
||||
const DEFAULT_NODE_USAGE_TYPE_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Node-type usage over the workflows in scope. Exactly one of `nodeTypes` and `workflows` is set:
|
||||
* the histogram when no node type was named, the workflows using it when one was.
|
||||
*/
|
||||
export interface NodeTypeUsage {
|
||||
/** Indexed, non-archived workflows in scope — the denominator for every count. */
|
||||
workflowsInScope: number;
|
||||
nodeTypes?: Array<{ nodeType: string; workflowCount: number }>;
|
||||
workflows?: Array<{ workflowId: string; name: string; updatedAt: Date }>;
|
||||
/**
|
||||
* Whether the limit cut the list short. On the histogram this also decides whether an absent
|
||||
* node type means "not used" or "not shown", so callers must not report absence as evidence
|
||||
* when it is true.
|
||||
*/
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
interface RawDepMaps {
|
||||
agentUsageMap: Map<string, Set<string>>;
|
||||
credMap: Map<string, Set<string>>;
|
||||
|
|
@ -53,6 +79,56 @@ export class WorkflowDependencyQueryService {
|
|||
private readonly moduleRegistry: ModuleRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Node-type usage across the workflows a user can read, read from the dependency index rather
|
||||
* than by fetching workflows.
|
||||
*
|
||||
* Without `nodeType` it returns the histogram; with one, the workflows using it. Either way the
|
||||
* denominator ships with the answer, because a count means nothing without it.
|
||||
*/
|
||||
async getNodeTypeUsage(
|
||||
user: User,
|
||||
options: { projectId?: string; nodeType?: string; limit?: number } = {},
|
||||
): Promise<NodeTypeUsage> {
|
||||
// The same scopes-to-roles step the workflow listing performs, so this reads exactly the
|
||||
// workflows a listing would return rather than applying a rule of its own.
|
||||
const [projectRoles, workflowRoles] = await Promise.all([
|
||||
this.roleService.rolesWithScope('project', ['workflow:read']),
|
||||
this.roleService.rolesWithScope('workflow', ['workflow:read']),
|
||||
]);
|
||||
const scope = {
|
||||
projectRoles,
|
||||
workflowRoles,
|
||||
...(options.projectId ? { projectId: options.projectId } : {}),
|
||||
};
|
||||
|
||||
if (!options.nodeType) {
|
||||
return await this.dependencyRepository.countNodeTypeUsage(
|
||||
user,
|
||||
scope,
|
||||
options.limit ?? DEFAULT_NODE_USAGE_TYPE_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
const limit = options.limit ?? DEFAULT_NODE_USAGE_WORKFLOW_LIMIT;
|
||||
const [rows, workflowsInScope] = await Promise.all([
|
||||
// One over the limit, so a truncated list is reported as truncated rather than guessed at.
|
||||
this.dependencyRepository.findWorkflowsUsingNodeType(
|
||||
user,
|
||||
scope,
|
||||
options.nodeType,
|
||||
limit + 1,
|
||||
),
|
||||
this.dependencyRepository.countWorkflowsInScope(user, scope),
|
||||
]);
|
||||
|
||||
return {
|
||||
workflowsInScope,
|
||||
workflows: rows.slice(0, limit),
|
||||
...(rows.length > limit ? { truncated: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async getDependencyCounts(
|
||||
resourceIds: string[],
|
||||
resourceType: DependencyResourceType,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import { testDb, createWorkflow } from '@n8n/backend-test-utils';
|
||||
import { WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db';
|
||||
import {
|
||||
testDb,
|
||||
createWorkflow,
|
||||
createTeamProject,
|
||||
linkUserToProject,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { Project } from '@n8n/db';
|
||||
import {
|
||||
SharedWorkflowRepository,
|
||||
WorkflowDependencyRepository,
|
||||
WorkflowDependencies,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { createMember, createOwner } from '../../shared/db/users';
|
||||
|
||||
describe('WorkflowDependencyRepository', () => {
|
||||
let workflowDependencyRepository: WorkflowDependencyRepository;
|
||||
|
||||
|
|
@ -262,3 +275,337 @@ describe('WorkflowDependencyRepository', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
const ANTHROPIC = '@n8n/n8n-nodes-langchain.lmChatAnthropic';
|
||||
const OPENAI = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const SLACK = 'n8n-nodes-base.slack';
|
||||
|
||||
/**
|
||||
* These back a context surface the agent reads to learn what a project is built out of, so the
|
||||
* thing worth asserting is the scoping: an aggregate that quietly spans projects the caller
|
||||
* cannot open would disclose what other teams use.
|
||||
*/
|
||||
describe('WorkflowDependencyRepository node usage', () => {
|
||||
let repository: WorkflowDependencyRepository;
|
||||
|
||||
/** Roles carrying `workflow:read`, as the caller derives them from scopes. */
|
||||
const readerScope = {
|
||||
projectRoles: ['project:admin', 'project:editor'],
|
||||
workflowRoles: ['workflow:owner', 'workflow:editor'],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
repository = Container.get(WorkflowDependencyRepository);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'WorkflowDependency',
|
||||
'SharedWorkflow',
|
||||
'WorkflowEntity',
|
||||
'ProjectRelation',
|
||||
'Project',
|
||||
'User',
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
/** A workflow holding one node per given type. `createWorkflow` indexes it on the way in, so
|
||||
* these tests read rows the real indexer would have written. */
|
||||
const seedWorkflow = async (
|
||||
project: Project,
|
||||
nodeTypes: string[],
|
||||
attributes: Partial<Parameters<typeof createWorkflow>[0]> = {},
|
||||
) =>
|
||||
await createWorkflow(
|
||||
{
|
||||
nodes: nodeTypes.map((type, index) => ({
|
||||
id: `node-${index}-${type}`,
|
||||
name: `${type}-${index}`,
|
||||
parameters: {},
|
||||
position: [0, 0] as [number, number],
|
||||
type,
|
||||
typeVersion: 1,
|
||||
})),
|
||||
...attributes,
|
||||
},
|
||||
project,
|
||||
);
|
||||
|
||||
describe('countNodeTypeUsage()', () => {
|
||||
it('counts only workflows in projects the user belongs to', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const member = await createMember();
|
||||
const theirs = await createTeamProject('theirs');
|
||||
await linkUserToProject(member, theirs, 'project:editor');
|
||||
const someoneElses = await createTeamProject('someone-elses');
|
||||
|
||||
await seedWorkflow(theirs, [ANTHROPIC]);
|
||||
await seedWorkflow(theirs, [ANTHROPIC, SLACK]);
|
||||
await seedWorkflow(someoneElses, [OPENAI]);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result.workflowsInScope).toBe(2);
|
||||
expect(result.nodeTypes).toEqual([
|
||||
{ nodeType: ANTHROPIC, workflowCount: 2 },
|
||||
{ nodeType: SLACK, workflowCount: 1 },
|
||||
]);
|
||||
// The whole point of the scoping: the other project's choice must not leak.
|
||||
expect(result.nodeTypes.map((row) => row.nodeType)).not.toContain(OPENAI);
|
||||
});
|
||||
|
||||
it('narrows to one project when given a projectId', async () => {
|
||||
const member = await createMember();
|
||||
const first = await createTeamProject('first');
|
||||
const second = await createTeamProject('second');
|
||||
await linkUserToProject(member, first, 'project:editor');
|
||||
await linkUserToProject(member, second, 'project:editor');
|
||||
|
||||
await seedWorkflow(first, [ANTHROPIC]);
|
||||
await seedWorkflow(second, [OPENAI]);
|
||||
|
||||
const result = await repository.countNodeTypeUsage(
|
||||
member,
|
||||
{ ...readerScope, projectId: first.id },
|
||||
50,
|
||||
);
|
||||
|
||||
expect(result.workflowsInScope).toBe(1);
|
||||
expect(result.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 1 }]);
|
||||
});
|
||||
|
||||
it('counts a node type once per workflow however many instances it holds', async () => {
|
||||
const member = await createMember();
|
||||
const project = await createTeamProject('project');
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
|
||||
await seedWorkflow(project, [SLACK, SLACK, SLACK]);
|
||||
|
||||
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
|
||||
|
||||
expect(result.nodeTypes).toEqual([{ nodeType: SLACK, workflowCount: 1 }]);
|
||||
});
|
||||
|
||||
it('excludes archived workflows from the counts and the denominator', async () => {
|
||||
const member = await createMember();
|
||||
const project = await createTeamProject('project');
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
|
||||
await seedWorkflow(project, [ANTHROPIC]);
|
||||
await seedWorkflow(project, [OPENAI], { isArchived: true });
|
||||
|
||||
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
|
||||
|
||||
expect(result.workflowsInScope).toBe(1);
|
||||
expect(result.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 1 }]);
|
||||
});
|
||||
|
||||
it('reads draft rows, not published ones', async () => {
|
||||
const member = await createMember();
|
||||
const project = await createTeamProject('project');
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
|
||||
const workflow = await seedWorkflow(project, [ANTHROPIC]);
|
||||
// A published snapshot of the same workflow, which must not double-count or override
|
||||
// what the draft says the workflow currently contains.
|
||||
const published = new WorkflowDependencies(workflow.id, 2, 'published-version-id');
|
||||
published.add({ dependencyType: 'nodeType', dependencyKey: OPENAI, dependencyInfo: null });
|
||||
await repository.updateDependenciesForWorkflow(workflow.id, published);
|
||||
|
||||
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
|
||||
|
||||
expect(result.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 1 }]);
|
||||
});
|
||||
|
||||
it('counts every project for a user with global workflow:read', async () => {
|
||||
const owner = await createOwner();
|
||||
const first = await createTeamProject('first');
|
||||
const second = await createTeamProject('second');
|
||||
|
||||
await seedWorkflow(first, [ANTHROPIC]);
|
||||
await seedWorkflow(second, [OPENAI]);
|
||||
|
||||
const result = await repository.countNodeTypeUsage(owner, readerScope, 50);
|
||||
|
||||
expect(result.workflowsInScope).toBe(2);
|
||||
expect(result.nodeTypes).toEqual([
|
||||
{ nodeType: ANTHROPIC, workflowCount: 1 },
|
||||
{ nodeType: OPENAI, workflowCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing for a user who belongs to no project', async () => {
|
||||
const member = await createMember();
|
||||
const someoneElses = await createTeamProject('someone-elses');
|
||||
await seedWorkflow(someoneElses, [ANTHROPIC]);
|
||||
|
||||
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
|
||||
|
||||
expect(result).toEqual({ workflowsInScope: 0, nodeTypes: [], truncated: false });
|
||||
});
|
||||
|
||||
it('caps the histogram at the limit and says the list is cut', async () => {
|
||||
const member = await createMember();
|
||||
const project = await createTeamProject('project');
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
|
||||
// One workflow per type, so every type has the same count and only the limit decides
|
||||
// how many come back.
|
||||
await seedWorkflow(project, [ANTHROPIC]);
|
||||
await seedWorkflow(project, [OPENAI]);
|
||||
await seedWorkflow(project, [SLACK]);
|
||||
|
||||
const capped = await repository.countNodeTypeUsage(member, readerScope, 2);
|
||||
const complete = await repository.countNodeTypeUsage(member, readerScope, 50);
|
||||
|
||||
expect(capped.nodeTypes).toHaveLength(2);
|
||||
expect(capped.truncated).toBe(true);
|
||||
// The denominator still describes the whole scope, not the shown rows.
|
||||
expect(capped.workflowsInScope).toBe(3);
|
||||
|
||||
expect(complete.nodeTypes).toHaveLength(3);
|
||||
expect(complete.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the most-used types when it cuts', async () => {
|
||||
const member = await createMember();
|
||||
const project = await createTeamProject('project');
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
|
||||
await seedWorkflow(project, [ANTHROPIC, SLACK]);
|
||||
await seedWorkflow(project, [ANTHROPIC]);
|
||||
await seedWorkflow(project, [OPENAI]);
|
||||
|
||||
const result = await repository.countNodeTypeUsage(member, readerScope, 1);
|
||||
|
||||
expect(result.nodeTypes).toEqual([{ nodeType: ANTHROPIC, workflowCount: 2 }]);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findWorkflowsUsingNodeType()', () => {
|
||||
it('returns the workflows using a type, most recently updated first, and scopes them', async () => {
|
||||
const member = await createMember();
|
||||
const theirs = await createTeamProject('theirs');
|
||||
await linkUserToProject(member, theirs, 'project:editor');
|
||||
const someoneElses = await createTeamProject('someone-elses');
|
||||
|
||||
const older = await seedWorkflow(theirs, [ANTHROPIC], { name: 'older' });
|
||||
const newer = await seedWorkflow(theirs, [ANTHROPIC], { name: 'newer' });
|
||||
await seedWorkflow(theirs, [OPENAI], { name: 'different type' });
|
||||
await seedWorkflow(someoneElses, [ANTHROPIC], { name: 'out of scope' });
|
||||
|
||||
// Make the ordering deterministic rather than relying on insertion timing.
|
||||
await Container.get(WorkflowRepository).update(older.id, {
|
||||
updatedAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
});
|
||||
await Container.get(WorkflowRepository).update(newer.id, {
|
||||
updatedAt: new Date('2030-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const rows = await repository.findWorkflowsUsingNodeType(member, readerScope, ANTHROPIC, 10);
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual(['newer', 'older']);
|
||||
expect(rows[0].workflowId).toBe(newer.id);
|
||||
expect(rows[0].updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('respects the limit', async () => {
|
||||
const member = await createMember();
|
||||
const project = await createTeamProject('project');
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
|
||||
await seedWorkflow(project, [SLACK]);
|
||||
await seedWorkflow(project, [SLACK]);
|
||||
await seedWorkflow(project, [SLACK]);
|
||||
|
||||
const rows = await repository.findWorkflowsUsingNodeType(member, readerScope, SLACK, 2);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scale', () => {
|
||||
// The design this replaces passed the workflow ids in as bind parameters, which caps out at
|
||||
// SQLite's 999-variable limit. Scope is a subquery here, so the assertion is that the counts
|
||||
// are exact past that point — not merely that nothing threw.
|
||||
it('returns exact counts for a project larger than the bind-parameter limit', async () => {
|
||||
const member = await createMember();
|
||||
const project = await createTeamProject('large');
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
|
||||
const workflowCount = 1_200;
|
||||
const anthropicCount = 700;
|
||||
|
||||
const workflowRepository = Container.get(WorkflowRepository);
|
||||
const sharedWorkflowRepository = Container.get(SharedWorkflowRepository);
|
||||
|
||||
// Built directly rather than through `createWorkflow` so the seed stays a few bulk
|
||||
// inserts instead of 1,200 round trips per table.
|
||||
const workflows = Array.from({ length: workflowCount }, (_, index) =>
|
||||
workflowRepository.create({
|
||||
name: `workflow-${index}`,
|
||||
active: false,
|
||||
isArchived: false,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
nodeGroups: [],
|
||||
settings: {},
|
||||
versionId: `version-${index}`,
|
||||
}),
|
||||
);
|
||||
// Chunked: a single multi-row INSERT of this many rows exceeds SQLite's expression-tree
|
||||
// depth limit, which is a property of the seed, not of the query under test.
|
||||
const insertInChunks = async <T>(rows: T[], insert: (chunk: T[]) => Promise<unknown>) => {
|
||||
for (let index = 0; index < rows.length; index += 200) {
|
||||
await insert(rows.slice(index, index + 200));
|
||||
}
|
||||
};
|
||||
|
||||
await insertInChunks(workflows, async (chunk) => await workflowRepository.insert(chunk));
|
||||
await insertInChunks(
|
||||
workflows.map((workflow) =>
|
||||
sharedWorkflowRepository.create({
|
||||
projectId: project.id,
|
||||
workflowId: workflow.id,
|
||||
role: 'workflow:owner',
|
||||
}),
|
||||
),
|
||||
async (chunk) => await sharedWorkflowRepository.insert(chunk),
|
||||
);
|
||||
await insertInChunks(
|
||||
workflows.map((workflow, index) => ({
|
||||
workflowId: workflow.id,
|
||||
workflowVersionId: 1,
|
||||
publishedVersionId: null,
|
||||
dependencyType: 'nodeType' as const,
|
||||
dependencyKey: index < anthropicCount ? ANTHROPIC : OPENAI,
|
||||
dependencyInfo: null,
|
||||
indexVersionId: 1,
|
||||
})),
|
||||
async (chunk) => await repository.insert(chunk),
|
||||
);
|
||||
|
||||
const result = await repository.countNodeTypeUsage(member, readerScope, 50);
|
||||
|
||||
expect(result.workflowsInScope).toBe(workflowCount);
|
||||
expect(result.nodeTypes).toEqual([
|
||||
{ nodeType: ANTHROPIC, workflowCount: anthropicCount },
|
||||
{ nodeType: OPENAI, workflowCount: workflowCount - anthropicCount },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user