Merge branch 'master' of github.com:n8n-io/n8n into agent-aia-sub-agent-spike

# Conflicts:
#	packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiChannelSetup.vue
This commit is contained in:
Michael Drury 2026-07-10 20:17:19 +01:00
commit aa4f936329
437 changed files with 16829 additions and 7656 deletions

View File

@ -521,9 +521,11 @@ dotenvx run -f .env.eval -- pnpm eval:langtracer-push --suite workflow-building
for MCP + REST) — put them in `.env.eval` and run under `dotenvx`.
- **Options:** `--set-kind regression|capability_gap` (default `regression`, must
match the suite's kind), `--contains-user-data` (default is `synthetic`).
- **Limitation:** `executionScenarios` are written on **create** only — the update
path patches case-level fields but not scenario rows (so scenario-only edits to
an existing case aren't re-synced; remove+re-push or edit in the lang-tracer UI).
- **Scenarios sync on update too:** `PATCH /cases/:id` reconciles
`executionScenarios` by name (update in place, insert new, delete missing —
lang-tracer #48), so scenario edits re-push like any other field. A lang-tracer
deployment predating that change silently ignores the key; if a pushed scenario
edit doesn't land, update the scenario in the lang-tracer UI.
- **Seeded cases can't be pushed:** the case-write API rejects every seeding mode
(`seedThread` / `seedFile` / `priorConversation`), so the push lists them under
`skipped:` and they never reach the suite. A `seedThread` case shouldn't be

File diff suppressed because one or more lines are too long

View File

@ -411,7 +411,9 @@ jobs:
post-qa-metrics-comment:
name: QA Metrics
needs: [required-checks, e2e-performance]
if: always()
# Only post when the perf suite actually measured this PR, otherwise the
# comment fires on trivial PRs and flags master drift/noise that isn't theirs.
if: always() && needs.e2e-performance.result == 'success'
uses: ./.github/workflows/util-qa-metrics-comment-reusable.yml
with:
metrics: memory-heap-used-baseline,memory-rss-baseline,instance-ai-heap-used-baseline,instance-ai-rss-baseline,agents-heap-used-baseline,agents-rss-baseline,docker-image-size-n8n,docker-image-size-runners,canvas-cold-load-S-ms,canvas-cold-load-M-ms,canvas-cold-load-L-ms,canvas-server-heap-M-mb,canvas-server-heap-L-mb,canvas-browser-heap-M-mb,canvas-browser-heap-L-mb,canvas-pan-frame-p95-M-ms,canvas-pan-frame-p95-L-ms,canvas-tidy-up-M-ms,canvas-tidy-up-L-ms,canvas-exec-heavy-M-ms,canvas-exec-heavy-L-ms

View File

@ -265,6 +265,18 @@ titles, test descriptions, and Linear URLs.
- **Linear references:** Never include the URL slug
(e.g. `.../N8N-1234/fix-ssrf-vulnerability`).
### Customer Confidentiality
**This is a public repository.** Never mention customer names in any
public-facing artifact — not all customers have agreed to be named publicly,
and naming them can reveal security-relevant details about their setup.
This applies to PR titles and descriptions, branch names, commit messages,
code, code comments, test names and test data, and fixtures. When implementing
a customer request, describe the use case neutrally (e.g. "a customer with a
large multi-main setup", not the company name) and use generic placeholder
names (e.g. `Acme Corp`) in tests and examples.
## Github Guidelines
- When creating a PR, use the conventions in
`.github/pull_request_template.md` and

View File

@ -92,7 +92,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.role_mapping_rule](public.role_mapping_rule.md) | 7 | | BASE TABLE |
| [public.role_mapping_rule_project](public.role_mapping_rule_project.md) | 2 | | BASE TABLE |
| [public.role_scope](public.role_scope.md) | 2 | | BASE TABLE |
| [public.scheduled_job](public.scheduled_job.md) | 17 | | BASE TABLE |
| [public.scheduled_job](public.scheduled_job.md) | 19 | | BASE TABLE |
| [public.scheduled_task](public.scheduled_task.md) | 16 | | BASE TABLE |
| [public.scope](public.scope.md) | 3 | | BASE TABLE |
| [public.secrets_provider_connection](public.secrets_provider_connection.md) | 7 | | BASE TABLE |
@ -1109,6 +1109,8 @@ erDiagram
timestamp_3__with_time_zone nextRunAt
varchar_36_ nodeId
json payload
integer recurrenceSize
varchar_16_ recurrenceUnit
varchar_128_ taskType
varchar_64_ timezone
timestamp_3__with_time_zone updatedAt

View File

@ -5,7 +5,7 @@
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| cronExpression | varchar(255) | | true | | | Cron expression driving recurrence; set only when kind is 'cron'. |
| cronExpression | varchar(255) | | true | | | Cron expression. For kind 'cron' it is the schedule; for 'recurring_cron' it lists the candidate run times that the every-N-periods filter then keeps every Nth of. |
| enabled | boolean | true | false | | | Whether the scheduler considers this job for firing. |
| fireAt | timestamp(3) with time zone | | true | | | Absolute time the job fires once; set only when kind is 'one_off'. |
| id | integer | | false | [public.scheduled_task](public.scheduled_task.md) | | |
@ -17,6 +17,8 @@
| nextRunAt | timestamp(3) with time zone | | true | | | Next time an occurrence is due; the scheduler sweep reads this to find work. NULL once disabled or a one-off has fired. |
| nodeId | varchar(36) | | true | | | Trigger node within the workflow that owns this job; NULL for non-trigger jobs. |
| payload | json | '{}'::json | false | | | Input passed to the task handler when an occurrence runs. |
| recurrenceSize | integer | | true | | | The N in a recurring_cron schedule's every-N-periods filter, e.g. 3 for every 3 weeks; at least 2. Set only when kind is 'recurring_cron'. |
| recurrenceUnit | varchar(16) | | true | | | Calendar period counted by a recurring_cron schedule's every-N-periods filter (hours, days, weeks, months). Set only when kind is 'recurring_cron'. |
| taskType | varchar(128) | | false | | | Selects which registered handler runs the task. |
| timezone | varchar(64) | | true | | | IANA timezone the cron expression is evaluated in; NULL uses the instance default. |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
@ -29,7 +31,10 @@
| CHK_scheduled_job_cron_expression | CHECK | CHECK ((((kind)::text <> 'cron'::text) OR ("cronExpression" IS NOT NULL))) |
| CHK_scheduled_job_fire_at | CHECK | CHECK ((((kind)::text <> 'one_off'::text) OR ("fireAt" IS NOT NULL))) |
| CHK_scheduled_job_interval_seconds | CHECK | CHECK ((((kind)::text <> 'interval'::text) OR ("intervalSeconds" IS NOT NULL))) |
| CHK_scheduled_job_kind | CHECK | CHECK (((kind)::text = ANY ((ARRAY['cron'::character varying, 'interval'::character varying, 'one_off'::character varying])::text[]))) |
| CHK_scheduled_job_kind | CHECK | CHECK (((kind)::text = ANY ((ARRAY['cron'::character varying, 'interval'::character varying, 'one_off'::character varying, 'recurring_cron'::character varying])::text[]))) |
| CHK_scheduled_job_recurrence_size | CHECK | CHECK (("recurrenceSize" >= 2)) |
| CHK_scheduled_job_recurrence_unit | CHECK | CHECK ((("recurrenceUnit")::text = ANY ((ARRAY['hours'::character varying, 'days'::character varying, 'weeks'::character varying, 'months'::character varying])::text[]))) |
| CHK_scheduled_job_recurring_cron | CHECK | CHECK ((((kind)::text <> 'recurring_cron'::text) OR (("cronExpression" IS NOT NULL) AND ("recurrenceUnit" IS NOT NULL) AND ("recurrenceSize" IS NOT NULL)))) |
| FK_scheduled_job_workflowId | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_published_version("workflowId") ON DELETE CASCADE |
| PK_893185383f029ca8d57bb781fa8 | PRIMARY KEY | PRIMARY KEY (id) |
| scheduled_job_createdAt_not_null | n | NOT NULL "createdAt" |
@ -73,6 +78,8 @@ erDiagram
timestamp_3__with_time_zone nextRunAt
varchar_36_ nodeId
json payload
integer recurrenceSize
varchar_16_ recurrenceUnit
varchar_128_ taskType
varchar_64_ timezone
timestamp_3__with_time_zone updatedAt

View File

@ -90,6 +90,8 @@ erDiagram
timestamp_3__with_time_zone nextRunAt
varchar_36_ nodeId
json payload
integer recurrenceSize
varchar_16_ recurrenceUnit
varchar_128_ taskType
varchar_64_ timezone
timestamp_3__with_time_zone updatedAt

View File

@ -91,6 +91,8 @@ erDiagram
timestamp_3__with_time_zone nextRunAt
varchar_36_ nodeId
json payload
integer recurrenceSize
varchar_16_ recurrenceUnit
varchar_128_ taskType
varchar_64_ timezone
timestamp_3__with_time_zone updatedAt

View File

@ -92,7 +92,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [role_mapping_rule](role_mapping_rule.md) | 7 | | table |
| [role_mapping_rule_project](role_mapping_rule_project.md) | 2 | | table |
| [role_scope](role_scope.md) | 2 | | table |
| [scheduled_job](scheduled_job.md) | 17 | | table |
| [scheduled_job](scheduled_job.md) | 19 | | table |
| [scheduled_task](scheduled_task.md) | 16 | | table |
| [scope](scope.md) | 3 | | table |
| [secrets_provider_connection](secrets_provider_connection.md) | 7 | | table |
@ -1096,6 +1096,8 @@ erDiagram
datetime_3_ nextRunAt
varchar_36_ nodeId
TEXT payload
INT recurrenceSize
varchar_16_ recurrenceUnit
varchar_128_ taskType
varchar_64_ timezone
datetime_3_ updatedAt

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "workflowId" varchar(36), "nodeId" varchar(36), "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "kind" varchar(16) NOT NULL, "cronExpression" varchar(255), "timezone" varchar(64), "intervalSeconds" integer, "fireAt" datetime(3), "enabled" boolean NOT NULL DEFAULT (true), "nextRunAt" datetime(3), "lastFiredAt" datetime(3), "maxAttempts" integer NOT NULL DEFAULT (1), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_scheduled_job_cron_expression" CHECK ("kind" <> 'cron' OR "cronExpression" IS NOT NULL), CONSTRAINT "CHK_scheduled_job_interval_seconds" CHECK ("kind" <> 'interval' OR "intervalSeconds" IS NOT NULL), CONSTRAINT "CHK_scheduled_job_fire_at" CHECK ("kind" <> 'one_off' OR "fireAt" IS NOT NULL), CONSTRAINT "CHK_scheduled_job_kind" CHECK ("kind" IN ('cron', 'interval', 'one_off')), CONSTRAINT "FK_scheduled_job_workflowId" FOREIGN KEY ("workflowId") REFERENCES "workflow_published_version" ("workflowId") ON DELETE CASCADE)
CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(255) NOT NULL, "workflowId" varchar(36), "nodeId" varchar(36), "taskType" varchar(128) NOT NULL, "payload" text NOT NULL DEFAULT ('{}'), "kind" varchar(16) NOT NULL, "cronExpression" varchar(255), "timezone" varchar(64), "intervalSeconds" integer, "fireAt" datetime(3), "enabled" boolean NOT NULL DEFAULT (true), "nextRunAt" datetime(3), "lastFiredAt" datetime(3), "maxAttempts" integer NOT NULL DEFAULT (1), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "recurrenceUnit" varchar(16), "recurrenceSize" int, CONSTRAINT "CHK_scheduled_job_recurrence_unit" CHECK (("recurrenceUnit" IN ('hours', 'days', 'weeks', 'months'))), CONSTRAINT "CHK_scheduled_job_recurrence_size" CHECK (("recurrenceSize" >= 2)), CONSTRAINT "CHK_scheduled_job_cron_expression" CHECK (("kind" <> 'cron' OR "cronExpression" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_interval_seconds" CHECK (("kind" <> 'interval' OR "intervalSeconds" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_fire_at" CHECK (("kind" <> 'one_off' OR "fireAt" IS NOT NULL)), CONSTRAINT "CHK_scheduled_job_kind" CHECK ("kind" IN ('cron', 'interval', 'one_off', 'recurring_cron')), CONSTRAINT "CHK_scheduled_job_recurring_cron" CHECK ("kind" <> 'recurring_cron' OR ("cronExpression" IS NOT NULL AND "recurrenceUnit" IS NOT NULL AND "recurrenceSize" IS NOT NULL)), CONSTRAINT "FK_scheduled_job_workflowId" FOREIGN KEY ("workflowId") REFERENCES "workflow_published_version" ("workflowId") ON DELETE CASCADE ON UPDATE NO ACTION)
```
</details>
@ -28,6 +28,8 @@ CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(
| nextRunAt | datetime(3) | | true | | | |
| nodeId | varchar(36) | | true | | | |
| payload | TEXT | '{}' | false | | | |
| recurrenceSize | INT | | true | | | |
| recurrenceUnit | varchar(16) | | true | | | |
| taskType | varchar(128) | | false | | | |
| timezone | varchar(64) | | true | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
@ -37,10 +39,13 @@ CREATE TABLE "scheduled_job" ("id" integer PRIMARY KEY NOT NULL, "name" varchar(
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("kind" <> 'cron' OR "cronExpression" IS NOT NULL) |
| - | CHECK | CHECK ("kind" <> 'interval' OR "intervalSeconds" IS NOT NULL) |
| - | CHECK | CHECK ("kind" <> 'one_off' OR "fireAt" IS NOT NULL) |
| - | CHECK | CHECK ("kind" IN ('cron', 'interval', 'one_off')) |
| - | CHECK | CHECK (("recurrenceUnit" IN ('hours', 'days', 'weeks', 'months'))) |
| - | CHECK | CHECK (("recurrenceSize" >= 2)) |
| - | CHECK | CHECK (("kind" <> 'cron' OR "cronExpression" IS NOT NULL)) |
| - | CHECK | CHECK (("kind" <> 'interval' OR "intervalSeconds" IS NOT NULL)) |
| - | CHECK | CHECK (("kind" <> 'one_off' OR "fireAt" IS NOT NULL)) |
| - | CHECK | CHECK ("kind" IN ('cron', 'interval', 'one_off', 'recurring_cron')) |
| - | CHECK | CHECK ("kind" <> 'recurring_cron' OR ("cronExpression" IS NOT NULL AND "recurrenceUnit" IS NOT NULL AND "recurrenceSize" IS NOT NULL)) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_published_version (workflowId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
@ -74,6 +79,8 @@ erDiagram
datetime_3_ nextRunAt
varchar_36_ nodeId
TEXT payload
INT recurrenceSize
varchar_16_ recurrenceUnit
varchar_128_ taskType
varchar_64_ timezone
datetime_3_ updatedAt

View File

@ -89,6 +89,8 @@ erDiagram
datetime_3_ nextRunAt
varchar_36_ nodeId
TEXT payload
INT recurrenceSize
varchar_16_ recurrenceUnit
varchar_128_ taskType
varchar_64_ timezone
datetime_3_ updatedAt

View File

@ -103,6 +103,8 @@ erDiagram
datetime_3_ nextRunAt
varchar_36_ nodeId
TEXT payload
INT recurrenceSize
varchar_16_ recurrenceUnit
varchar_128_ taskType
varchar_64_ timezone
datetime_3_ updatedAt

View File

@ -12,6 +12,7 @@ describe('ImportPackageRequestDto', () => {
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
folderConflictPolicy: 'merge',
});
}
});
@ -31,6 +32,7 @@ describe('ImportPackageRequestDto', () => {
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
folderConflictPolicy: 'merge',
});
}
});
@ -52,6 +54,7 @@ describe('ImportPackageRequestDto', () => {
workflowConflictPolicy: 'new-version',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
folderConflictPolicy: 'merge',
});
}
});
@ -72,6 +75,7 @@ describe('ImportPackageRequestDto', () => {
workflowConflictPolicy: 'skip',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
folderConflictPolicy: 'merge',
});
}
});

View File

@ -12,6 +12,7 @@ export const IMPORT_PACKAGE_REQUEST_FORM_FIELDS = [
'workflowConflictPolicy',
'workflowPublishingPolicy',
'workflowIdPolicy',
'folderConflictPolicy',
] as const;
/** Multipart text fields: empty / whitespace-only values become `undefined`. */
@ -72,4 +73,5 @@ export class ImportPackageRequestDto extends Z.class({
.optional()
.default('preserve-published-state'),
workflowIdPolicy: z.enum(['new', 'source']).optional().default('new'),
folderConflictPolicy: z.enum(['merge', 'fail']).optional().default('merge'),
}) {}

View File

@ -41,8 +41,15 @@ function makeTextDelta(
runId: string,
agentId: string,
text: string,
responseId?: string,
): Extract<InstanceAiEvent, { type: 'text-delta' }> {
return { type: 'text-delta', runId, agentId, payload: { text } };
return {
type: 'text-delta',
runId,
agentId,
...(responseId ? { responseId } : {}),
payload: { text },
};
}
function makeReasoningDelta(
@ -1197,4 +1204,225 @@ describe('agent-run-reducer', () => {
expect(child.timeline).toEqual([{ type: 'reasoning', content: 'Child reasoning' }]);
});
});
describe('durable-log replay (block replace semantics + interrupted facts)', () => {
const RUN = 'run-dl';
const AGENT = 'orchestrator-run-dl';
function makeTextBlock(
text: string,
responseId?: string,
): Extract<InstanceAiEvent, { type: 'text-block' }> {
return {
type: 'text-block',
runId: RUN,
agentId: AGENT,
...(responseId ? { responseId } : {}),
payload: { text },
};
}
function makeReasoningBlock(
text: string,
responseId?: string,
): Extract<InstanceAiEvent, { type: 'reasoning-block' }> {
return {
type: 'reasoning-block',
runId: RUN,
agentId: AGENT,
...(responseId ? { responseId } : {}),
payload: { text },
};
}
it('text-block REPLACES the open streamed segment with the same responseId', () => {
// Mid-block reconnect: the client saw partial deltas live, then the
// replayed block carries the segment's full text.
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
state = reduceEvent(state, makeTextDelta(RUN, AGENT, 'AAA', 'msg-open'));
state = reduceEvent(state, makeTextDelta(RUN, AGENT, 'BBB', 'msg-open'));
state = reduceEvent(state, makeTextBlock('AAABBBCCC', 'msg-open'));
const agent = findAgent(state, AGENT)!;
expect(agent.textContent).toBe('AAABBBCCC');
expect(agent.timeline.filter((e) => e.type === 'text')).toEqual([
{ type: 'text', content: 'AAABBBCCC', responseId: 'msg-open' },
]);
});
it('reasoning-block strips exactly the open segment before appending the full text', () => {
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
// A previously closed reasoning segment must be preserved verbatim.
state = reduceEvent(state, makeReasoningDelta(RUN, AGENT, 'first thoughts. ', 'msg-1'));
state = reduceEvent(state, makeReasoningBlock('first thoughts. ', 'msg-1'));
// Open segment: two partial deltas, then the replayed block.
state = reduceEvent(state, makeReasoningDelta(RUN, AGENT, 'sec', 'msg-2'));
state = reduceEvent(state, makeReasoningDelta(RUN, AGENT, 'ond', 'msg-2'));
state = reduceEvent(state, makeReasoningBlock('second thoughts.', 'msg-2'));
const agent = findAgent(state, AGENT)!;
expect(agent.reasoning).toBe('first thoughts. second thoughts.');
expect(agent.timeline.filter((e) => e.type === 'reasoning')).toEqual([
{ type: 'reasoning', content: 'first thoughts. ', responseId: 'msg-1' },
{ type: 'reasoning', content: 'second thoughts.', responseId: 'msg-2' },
]);
});
it('mid-block reconnect deep-equals the never-disconnected state (text)', () => {
// Never disconnected: the full delta stream, then the closing block.
let live = createInitialState(AGENT);
live = reduceEvent(live, makeRunStart(RUN, AGENT));
for (const text of ['AAA', 'BBB', 'CCC']) {
live = reduceEvent(live, makeTextDelta(RUN, AGENT, text, 'msg-open'));
}
live = reduceEvent(live, makeTextBlock('AAABBBCCC', 'msg-open'));
// Reconnected mid-block: partial deltas seen live, then the replayed
// block (deltas are never persisted, so the tail is never re-sent).
let reconnected = createInitialState(AGENT);
reconnected = reduceEvent(reconnected, makeRunStart(RUN, AGENT));
for (const text of ['AAA', 'BBB']) {
reconnected = reduceEvent(reconnected, makeTextDelta(RUN, AGENT, text, 'msg-open'));
}
reconnected = reduceEvent(reconnected, makeTextBlock('AAABBBCCC', 'msg-open'));
expect(reconnected).toEqual(live);
});
it('mid-block reconnect deep-equals the never-disconnected state (reasoning)', () => {
let live = createInitialState(AGENT);
live = reduceEvent(live, makeRunStart(RUN, AGENT));
for (const text of ['deep ', 'thoughts', '...']) {
live = reduceEvent(live, makeReasoningDelta(RUN, AGENT, text, 'msg-open'));
}
live = reduceEvent(live, makeReasoningBlock('deep thoughts...', 'msg-open'));
let reconnected = createInitialState(AGENT);
reconnected = reduceEvent(reconnected, makeRunStart(RUN, AGENT));
for (const text of ['deep ', 'thoughts']) {
reconnected = reduceEvent(reconnected, makeReasoningDelta(RUN, AGENT, text, 'msg-open'));
}
reconnected = reduceEvent(reconnected, makeReasoningBlock('deep thoughts...', 'msg-open'));
expect(reconnected).toEqual(live);
});
it('a block with a DIFFERENT responseId appends instead of replacing', () => {
// Pure replay (no live deltas seen): every block appends.
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
state = reduceEvent(state, makeTextBlock('first segment. ', 'msg-1'));
state = reduceEvent(state, makeTextBlock('second segment.', 'msg-2'));
const agent = findAgent(state, AGENT)!;
expect(agent.textContent).toBe('first segment. second segment.');
expect(agent.timeline.filter((e) => e.type === 'text')).toHaveLength(2);
});
it('adjacent id-less blocks append — undefined ids never match as one segment', () => {
// Synthetic blocks (crash-resume markers, correction lines) carry no
// responseId; the second must not overwrite the first.
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
state = reduceEvent(state, makeTextBlock('first line. '));
state = reduceEvent(state, makeTextBlock('second line.'));
const agent = findAgent(state, AGENT)!;
expect(agent.textContent).toBe('first line. second line.');
// Same-key adjacency merges into one display entry — but never drops text.
expect(agent.timeline.filter((e) => e.type === 'text')).toEqual([
{ type: 'text', content: 'first line. second line.' },
]);
});
it('an id-less block never replaces, even when it textually extends the previous one', () => {
// Id-less blocks have no identity to match on, so text alone must
// never decide a replace: a block that merely happens to start with
// the previous block's text would silently overwrite it. Stream
// segments are stamped with responseIds, so the replace path never
// needs the id-less case.
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
state = reduceEvent(state, makeTextBlock('Hello. '));
state = reduceEvent(state, makeTextBlock('Hello. World.'));
const agent = findAgent(state, AGENT)!;
// Nothing dropped: both blocks' text present, in order.
expect(agent.textContent).toBe('Hello. Hello. World.');
expect(agent.timeline.filter((e) => e.type === 'text')).toEqual([
{ type: 'text', content: 'Hello. Hello. World.' },
]);
});
it('a block reusing a responseId with unrelated text appends', () => {
// Same id but not a textual extension of the open entry: this is a new
// message, not the completion of the partial one.
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
state = reduceEvent(state, makeTextBlock('Task one finished. ', 'bg-outcome:run-dl'));
state = reduceEvent(state, makeTextBlock('Task two failed.', 'bg-outcome:run-dl'));
const agent = findAgent(state, AGENT)!;
expect(agent.textContent).toBe('Task one finished. Task two failed.');
expect(agent.timeline.filter((e) => e.type === 'text')).toEqual([
{
type: 'text',
content: 'Task one finished. Task two failed.',
responseId: 'bg-outcome:run-dl',
},
]);
});
it('adjacent id-less reasoning-blocks append — undefined ids never match as one segment', () => {
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
state = reduceEvent(state, makeReasoningBlock('first thoughts. '));
state = reduceEvent(state, makeReasoningBlock('second thoughts.'));
const agent = findAgent(state, AGENT)!;
expect(agent.reasoning).toBe('first thoughts. second thoughts.');
expect(agent.timeline.filter((e) => e.type === 'reasoning')).toEqual([
{ type: 'reasoning', content: 'first thoughts. second thoughts.' },
]);
});
it('an id-less reasoning-block never replaces, even when it textually extends the previous one', () => {
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
state = reduceEvent(state, makeReasoningBlock('so far. '));
state = reduceEvent(state, makeReasoningBlock('so far. and further.'));
const agent = findAgent(state, AGENT)!;
expect(agent.reasoning).toBe('so far. so far. and further.');
expect(agent.timeline.filter((e) => e.type === 'reasoning')).toEqual([
{ type: 'reasoning', content: 'so far. so far. and further.' },
]);
});
it('tool-interrupted resolves the call terminally and run-finish{interrupted} folds to cancelled', () => {
let state = createInitialState(AGENT);
state = reduceEvent(state, makeRunStart(RUN, AGENT));
state = reduceEvent(state, makeToolCall(RUN, AGENT, 'tc-1', 'update-workflow'));
state = reduceEvent(state, {
type: 'tool-interrupted',
runId: RUN,
agentId: AGENT,
payload: { toolCallId: 'tc-1', error: 'Interrupted by a process restart' },
});
state = reduceEvent(state, {
type: 'run-finish',
runId: RUN,
agentId: AGENT,
payload: { status: 'interrupted', reason: 'crash_interrupted' },
});
const tc = state.toolCallsById['tc-1'];
expect(tc.isLoading).toBe(false);
expect(tc.error).toContain('Interrupted');
expect(state.status).toBe('cancelled');
const agent = findAgent(state, AGENT)!;
expect(agent.cancellationReason).toBe('interrupted');
});
});
});

View File

@ -35,6 +35,7 @@ function categorizeCancellation(
if (reason === 'timeout') return 'timeout';
if (reason === 'service_shutdown') return 'shutdown';
if (reason === 'user_cancelled') return 'user';
if (reason === 'crash_interrupted') return 'interrupted';
return undefined;
}
@ -240,6 +241,67 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
break;
}
case 'text-block': {
// Coalesced segment from the durable log (replay path). When the last
// timeline entry is this segment's streamed deltas (mid-block reconnect:
// the client saw part of the text live), REPLACE it instead of appending
// so no text renders twice. The log flushes a block immediately before
// the segment's next structural fact, so on replay the partial deltas
// are always the last text entry when this event arrives.
// Replace requires a PRESENT, matching responseId: id-less blocks
// (synthetic markers, backfill) have no identity to match on, so they
// always append — two of them can never overwrite each other. The
// block must also textually extend the entry (deltas stream in order,
// so a genuine partial is always a prefix); the same id reused with
// unrelated text is a new message, not the open segment.
const agent = ensureAgent(state, event.agentId);
if (agent) {
const last = agent.timeline.at(-1);
const isOpenSegment =
last?.type === 'text' &&
event.responseId !== undefined &&
last.responseId === event.responseId &&
event.payload.text.startsWith(last.content);
if (isOpenSegment && agent.textContent.endsWith(last.content)) {
agent.textContent =
agent.textContent.slice(0, agent.textContent.length - last.content.length) +
event.payload.text;
last.content = event.payload.text;
} else {
agent.textContent += event.payload.text;
appendTimelineText(agent.timeline, event.payload.text, event.responseId);
}
}
break;
}
case 'reasoning-block': {
// Coalesced reasoning segment from the durable log (replay path). Same
// replace semantics as text-block (present matching responseId plus
// textual extension): the segment's open streamed deltas are its
// timeline entry, so REPLACE that entry and strip the partial text
// from the aggregate — no text renders twice.
const agent = ensureAgent(state, event.agentId);
if (agent) {
const last = agent.timeline.at(-1);
const isOpenSegment =
last?.type === 'reasoning' &&
event.responseId !== undefined &&
last.responseId === event.responseId &&
event.payload.text.startsWith(last.content);
if (isOpenSegment && agent.reasoning.endsWith(last.content)) {
agent.reasoning =
agent.reasoning.slice(0, agent.reasoning.length - last.content.length) +
event.payload.text;
last.content = event.payload.text;
} else {
agent.reasoning += event.payload.text;
appendTimelineReasoning(agent.timeline, event.payload.text, event.responseId);
}
}
break;
}
case 'tool-call': {
if (!isSafeObjectKey(event.payload.toolCallId)) break;
const agent = ensureAgent(state, event.agentId);
@ -285,6 +347,19 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
break;
}
case 'tool-interrupted': {
// Durable fact for a tool call in flight when the process died:
// terminal like tool-error, effect unverified, never blind-retried.
if (!isSafeObjectKey(event.payload.toolCallId)) break;
const tc = state.toolCallsById[event.payload.toolCallId];
if (tc) {
tc.error = event.payload.error;
tc.isLoading = false;
tc.completedAt = new Date().toISOString();
}
break;
}
case 'agent-spawned': {
if (!isSafeObjectKey(event.agentId) || !isSafeObjectKey(event.payload.parentId)) break;
// Idempotency guard: a replayed agent-spawned for an existing agent
@ -393,8 +468,14 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
case 'run-finish': {
const { status } = event.payload;
// 'interrupted' renders as a cancellation whose reason attributes the
// crash — no dedicated FE state needed.
state.status =
status === 'completed' ? 'completed' : status === 'cancelled' ? 'cancelled' : 'error';
status === 'completed'
? 'completed'
: status === 'cancelled' || status === 'interrupted'
? 'cancelled'
: 'error';
const root = state.agentsById[state.rootAgentId];
if (root) {
root.status = state.status;

View File

@ -1,2 +1,8 @@
export type FavoriteResourceType = 'workflow' | 'project' | 'dataTable' | 'folder';
export const FAVORITE_RESOURCE_TYPES = ['workflow', 'project', 'dataTable', 'folder'] as const;
export type FavoriteResourceType = 'workflow' | 'project' | 'dataTable' | 'folder' | 'agent';
export const FAVORITE_RESOURCE_TYPES = [
'workflow',
'project',
'dataTable',
'folder',
'agent',
] as const;

View File

@ -102,9 +102,12 @@ export const instanceAiEventTypeSchema = z.enum([
'agent-completed',
'text-delta',
'reasoning-delta',
'text-block',
'reasoning-block',
'tool-call',
'tool-result',
'tool-error',
'tool-interrupted',
'confirmation-request',
'tasks-update',
'filesystem-request',
@ -118,7 +121,10 @@ export type InstanceAiEventType = z.infer<typeof instanceAiEventTypeSchema>;
// Run status
// ---------------------------------------------------------------------------
export const instanceAiRunStatusSchema = z.enum(['completed', 'cancelled', 'error']);
// 'interrupted' (durable-log RFC, resilience phase): appended by the
// interrupted-run sweep for a run whose process died mid-flight — the fold
// renders every in-flight item as terminated, no walk-and-mutate.
export const instanceAiRunStatusSchema = z.enum(['completed', 'cancelled', 'error', 'interrupted']);
export type InstanceAiRunStatus = z.infer<typeof instanceAiRunStatusSchema>;
// ---------------------------------------------------------------------------
@ -712,9 +718,27 @@ export const instanceAiEventSchema = z.discriminatedUnion('type', [
...eventBase,
payload: reasoningDeltaPayloadSchema,
}),
// Coalesced full text/reasoning of one streamed segment, produced by the
// durable event log (deltas are live-only and never persisted). On replay the
// reducer REPLACES the segment's streamed deltas, so a client that reconnects
// mid-block cannot see partial text twice.
z.object({ type: z.literal('text-block'), ...eventBase, payload: textDeltaPayloadSchema }),
z.object({
type: z.literal('reasoning-block'),
...eventBase,
payload: reasoningDeltaPayloadSchema,
}),
z.object({ type: z.literal('tool-call'), ...eventBase, payload: toolCallPayloadSchema }),
z.object({ type: z.literal('tool-result'), ...eventBase, payload: toolResultPayloadSchema }),
z.object({ type: z.literal('tool-error'), ...eventBase, payload: toolErrorPayloadSchema }),
// Durable-log RFC (resilience phase): appended by the interrupted-run sweep
// for a tool call that was in flight when the process died. Same payload
// shape as tool-error; the effect is unverified, never re-executed blindly.
z.object({
type: z.literal('tool-interrupted'),
...eventBase,
payload: toolErrorPayloadSchema,
}),
z.object({
type: z.literal('confirmation-request'),
...eventBase,
@ -965,7 +989,7 @@ export interface InstanceAiAgentNode {
}
/** Semantic cause of a cancelled run, mapped from the backend's run-finish reason. */
export type InstanceAiCancellationReason = 'user' | 'timeout' | 'shutdown';
export type InstanceAiCancellationReason = 'user' | 'timeout' | 'shutdown' | 'interrupted';
export interface InstanceAiMessage {
id: string;

View File

@ -47,6 +47,7 @@ n8n-cli package import --file=export.n8np --conflict-policy=fail --bindings='{"c
| `--project` | Target project ID. Defaults to your personal project. |
| `--folder` | Target folder ID within the project. Defaults to the project root. |
| `--workflow-id-policy` | Whether imported workflows keep their source ID (`source`) or receive a new one (`new`). |
| `--folder-conflict-policy` | What to do when a package folder already exists in the target project: `merge` (default, reuse the existing folder and merge the package's children into it) or `fail`. Requires a folders-enabled license when the package contains folders. |
| `--credential-matching-mode` | How credential references are matched on the target instance: `id-only` (default, match by id), `name-and-type` (match by exact name and type), or `type-only` (match by type). For `name-and-type` and `type-only`, candidates are ranked by scope — owned by the target project, then shared into it, then global — and ties within a scope use the most recently updated credential. |
| `--credential-missing-mode` | What to do when a referenced credential cannot be resolved. `create-stub` (instance default) creates empty placeholder credentials in the target project; `must-preexist` requires every referenced credential to already exist. |
| `--bindings` | Explicit source→target id bindings as a JSON object keyed by entity type, e.g. `{"credentials":{"<sourceId>":"<targetId>"}}`. Only `credentials` is honoured today; these bindings are applied before `--credential-matching-mode` resolution runs. |

View File

@ -17,6 +17,7 @@ export interface ImportPackageFields {
bindings?: string;
workflowConflictPolicy: string;
workflowIdPolicy?: string;
folderConflictPolicy?: string;
}
export class ApiError extends Error {

View File

@ -35,6 +35,11 @@ export default class PackageImport extends BaseCommand {
options: ['new', 'source'],
aliases: ['workflow-id-policy'],
}),
folderConflictPolicy: Flags.string({
description: 'What to do when a package folder already exists in the target project',
options: ['merge', 'fail'],
aliases: ['folder-conflict-policy'],
}),
credentialMatchingMode: Flags.string({
description: 'How credential references are matched on the target instance',
options: ['id-only', 'name-and-type', 'type-only'],
@ -71,6 +76,7 @@ export default class PackageImport extends BaseCommand {
folderId: flags.folder,
workflowConflictPolicy: flags.conflictPolicy,
workflowIdPolicy: flags.workflowIdPolicy,
folderConflictPolicy: flags.folderConflictPolicy,
credentialMatchingMode: flags.credentialMatchingMode,
credentialMissingMode: flags.credentialMissingMode,
bindings: flags.bindings,

View File

@ -8,13 +8,17 @@
*/
/**
* Recurrence kind of a scheduled job.
* It selects which schedule columns apply.
* How a scheduled job decides when to fire:
* - on a cron expression
* - on a fixed interval
* - just once
* - or on a cron repeated every N periods.
*/
export const ScheduledJobKind = {
Cron: 'cron',
Interval: 'interval',
OneOff: 'one_off',
RecurringCron: 'recurring_cron',
} as const;
export type ScheduledJobKind = (typeof ScheduledJobKind)[keyof typeof ScheduledJobKind];
@ -22,6 +26,28 @@ export type ScheduledJobKind = (typeof ScheduledJobKind)[keyof typeof ScheduledJ
/** All recurrence kinds as a runtime list. */
export const ScheduledJobKindList = Object.values(ScheduledJobKind);
/**
* The unit of recurrence for a `recurring_cron` schedule: a cron expression
* repeated every N of these. For example:
* - `weeks` for "every 3 weeks"
* - `hours` for "every 5 hours"
*
* Only these coarse units are supported here.
* Finer cadences (every N seconds or minutes) are expressed as a different schedule instead
* (a plain `interval`, or a stepped cron expression, see {@link ScheduledJobKind}),
* so they never go through this recurrence step.
*/
export const RecurringCronUnit = {
Hours: 'hours',
Days: 'days',
Weeks: 'weeks',
Months: 'months',
} as const;
export type RecurringCronUnit = (typeof RecurringCronUnit)[keyof typeof RecurringCronUnit];
export const RecurringCronUnitList = Object.values(RecurringCronUnit);
/**
* Where a scheduled task is in its lifecycle, from waiting to run to a final outcome.
*/

View File

@ -1,4 +1,4 @@
import type { ScheduledJobKind } from '@n8n/constants';
import type { ScheduledJobKind, RecurringCronUnit } from '@n8n/constants';
import { Column, Entity, Index, PrimaryGeneratedColumn } from '@n8n/typeorm';
import { DateTimeColumn, JsonColumn, WithTimestamps } from './abstract-entity';
@ -77,7 +77,7 @@ export class ScheduledJob extends WithTimestamps {
/**
* Cron expression driving recurrence.
* Set only when {@link kind} is `cron`.
* Set when {@link kind} is `cron` or `recurring_cron`.
*/
@Column({ type: 'varchar', length: 255, nullable: true })
cronExpression: string | null;
@ -103,6 +103,21 @@ export class ScheduledJob extends WithTimestamps {
@DateTimeColumn({ nullable: true })
fireAt: Date | null;
/**
* Calendar period the every-Nth-period recurrence gate counts in.
* Set only when {@link kind} is `recurring_cron`.
*/
@Column({ type: 'varchar', length: 16, nullable: true })
recurrenceUnit: RecurringCronUnit | null;
/**
* How many periods between fires, e.g. 3 for "every 3 weeks".
* At least 2: a stride of 1 keeps every fire, which is just a plain `cron`.
* Set only when {@link kind} is `recurring_cron`.
*/
@Column({ type: 'int', nullable: true })
recurrenceSize: number | null;
@Column({ default: true })
enabled: boolean;

View File

@ -0,0 +1,154 @@
import { TableCheck } from '@n8n/typeorm';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = 'scheduled_job';
const taskTable = 'scheduled_task';
const kindColumn = 'kind';
const kindValuesBefore = ['cron', 'interval', 'one_off'];
const kindValues = [...kindValuesBefore, 'recurring_cron'];
const recurrenceUnitValues = ['hours', 'days', 'weeks', 'months'];
/**
* Adds the `recurring_cron` schedule kind and its two supporting columns.
*
* A `recurring_cron` schedule pairs a cron expression with an "every N periods"
* filter, so it can express schedules a cron expression cannot on its own, e.g.
* "every 3 weeks on Mon and Wed" or "every 5 hours". The cron expression lists
* the candidate run times (reusing the existing `cronExpression` / `timezone`
* columns); the filter then keeps only every Nth of them. The N and the period
* it counts live in the two new columns:
* - `recurrenceUnit`: the calendar period counted (hours, days, weeks, months)
* - `recurrenceSize`: the N, e.g. 3 for "every 3 weeks" (at least 2)
*/
export class AddRecurringCronScheduleKind1784000000045 implements ReversibleMigration {
async up(context: MigrationContext) {
await this.addRecurrenceColumns(context);
// `addRecurrenceColumns` adds the two columns via raw `ALTER TABLE ADD
// COLUMN`, which TypeORM doesn't observe, so its cached `Table` metadata is
// stale. On SQLite the next steps rebuild the whole table from that cache
// (see `widenKindCheck` / `addRecurringCronPresenceCheck`); without this
// refresh the rebuild would silently drop the two new columns.
// `getTable()` reloads the real schema from PRAGMA table_info.
if (context.isSqlite) {
await context.queryRunner.getTable(`${context.tablePrefix}${table}`);
}
await this.widenKindCheck(context);
await this.addRecurringCronPresenceCheck(context);
if (context.isPostgres) {
await this.commentColumns(context);
}
}
async down(context: MigrationContext) {
const { escape, queryRunner, tablePrefix, schemaBuilder } = context;
const jobTable = escape.tableName(table);
const kind = escape.columnName(kindColumn);
// recurring_cron rows cannot exist under the narrowed kind CHECK. Their
// queued occurrences are removed explicitly rather than via the job ->
// task CASCADE, which is off while SQLite runs with FKs disabled.
await context.runQuery(
`DELETE FROM ${escape.tableName(taskTable)} WHERE ${escape.columnName('jobId')} IN ` +
`(SELECT ${escape.columnName('id')} FROM ${jobTable} WHERE ${kind} = 'recurring_cron')`,
);
await context.runQuery(`DELETE FROM ${jobTable} WHERE ${kind} = 'recurring_cron'`);
await queryRunner.dropCheckConstraint(
`${tablePrefix}${table}`,
`CHK_${tablePrefix}scheduled_job_recurring_cron`,
);
await schemaBuilder.dropEnumCheck(table, kindColumn, { recreatesOnSqlite: true });
await schemaBuilder.addEnumCheck(table, kindColumn, kindValuesBefore, {
recreatesOnSqlite: true,
});
// The columns' own CHECK constraints must go before the columns: TypeORM's
// SQLite table rebuild keeps checks referencing dropped columns, and SQLite
// then reads `"recurrenceUnit"` as a string literal, failing every row copy.
await queryRunner.dropCheckConstraint(
`${tablePrefix}${table}`,
`CHK_${tablePrefix}scheduled_job_recurrence_unit`,
);
await queryRunner.dropCheckConstraint(
`${tablePrefix}${table}`,
`CHK_${tablePrefix}scheduled_job_recurrence_size`,
);
await schemaBuilder.dropColumns(table, ['recurrenceUnit', 'recurrenceSize'], {
recreatesOnSqlite: true,
});
if (context.isPostgres) {
// Restore the comment `up()` overwrote (wording from CreateSchedulerTables).
await context.runQuery(
`COMMENT ON COLUMN ${jobTable}.${escape.columnName('cronExpression')} IS ` +
"'Cron expression driving recurrence; set only when kind is ''cron''.'",
);
}
}
/**
* Adds the two columns with raw `ADD COLUMN` rather than the schema builder's
* `addColumns`, so SQLite doesn't rebuild the whole table just to add two
* nullable columns. Each column carries a CHECK on its own value (valid unit,
* size >= 2). Enforcing that both are set together on `recurring_cron` rows is
* a separate constraint, see `addRecurringCronPresenceCheck`.
*/
private async addRecurrenceColumns({ runQuery, escape, tablePrefix }: MigrationContext) {
const jobTable = escape.tableName(table);
const recurrenceUnit = escape.columnName('recurrenceUnit');
const recurrenceSize = escape.columnName('recurrenceSize');
const unitValues = recurrenceUnitValues.map((value) => `'${value}'`).join(', ');
await runQuery(
`ALTER TABLE ${jobTable} ADD COLUMN ${recurrenceUnit} varchar(16) ` +
`CONSTRAINT "CHK_${tablePrefix}scheduled_job_recurrence_unit" CHECK (${recurrenceUnit} IN (${unitValues}))`,
);
await runQuery(
`ALTER TABLE ${jobTable} ADD COLUMN ${recurrenceSize} int ` +
`CONSTRAINT "CHK_${tablePrefix}scheduled_job_recurrence_size" CHECK (${recurrenceSize} >= 2)`,
);
}
/**
* Adds `recurring_cron` to the values the `kind` CHECK constraint accepts
* (recreates the table on SQLite). Drops then re-adds the constraint: the
* original was created by the schema builder's `withEnumCheck`, so
* `dropEnumCheck` finds it by its deterministic name.
*/
private async widenKindCheck({ schemaBuilder }: MigrationContext) {
await schemaBuilder.dropEnumCheck(table, kindColumn, { recreatesOnSqlite: true });
await schemaBuilder.addEnumCheck(table, kindColumn, kindValues, { recreatesOnSqlite: true });
}
private async addRecurringCronPresenceCheck({ queryRunner, tablePrefix }: MigrationContext) {
await queryRunner.createCheckConstraint(
`${tablePrefix}${table}`,
new TableCheck({
name: `CHK_${tablePrefix}scheduled_job_recurring_cron`,
expression:
'"kind" <> \'recurring_cron\' OR ("cronExpression" IS NOT NULL AND "recurrenceUnit" IS NOT NULL AND "recurrenceSize" IS NOT NULL)',
}),
);
}
private async commentColumns({ runQuery, escape }: MigrationContext) {
const jobTable = escape.tableName(table);
await runQuery(
`COMMENT ON COLUMN ${jobTable}.${escape.columnName('recurrenceUnit')} IS ` +
"'Calendar period counted by a recurring_cron schedule''s every-N-periods filter (hours, days, weeks, months). Set only when kind is ''recurring_cron''.'",
);
await runQuery(
`COMMENT ON COLUMN ${jobTable}.${escape.columnName('recurrenceSize')} IS ` +
"'The N in a recurring_cron schedule''s every-N-periods filter, e.g. 3 for every 3 weeks; at least 2. Set only when kind is ''recurring_cron''.'",
);
await runQuery(
`COMMENT ON COLUMN ${jobTable}.${escape.columnName('cronExpression')} IS ` +
"'Cron expression. For kind ''cron'' it is the schedule; for ''recurring_cron'' it lists the candidate run times that the every-N-periods filter then keeps every Nth of.'",
);
}
}

View File

@ -218,6 +218,7 @@ import { CreateWorkflowPublicationTriggerStatusTable1784000000040 } from '../com
import { AddUsedPrivateCredentialsToExecutionEntity1784000000041 } from '../common/1784000000041-AddUsedPrivateCredentialsToExecutionEntity';
import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-CreateSchedulerTables';
import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials';
import { AddRecurringCronScheduleKind1784000000045 } from '../common/1784000000045-AddRecurringCronScheduleKind';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@ -441,4 +442,5 @@ export const postgresMigrations: Migration[] = [
CreateSchedulerTables1784000000042,
CreateWorkflowStatisticsDeltaTable1784000000043,
AddPartialIndexForGlobalCredentials1784000000044,
AddRecurringCronScheduleKind1784000000045,
];

View File

@ -0,0 +1,12 @@
import { AddRecurringCronScheduleKind1784000000045 as BaseMigration } from '../common/1784000000045-AddRecurringCronScheduleKind';
/**
* On SQLite, changing the CHECK constraints (both directions) and dropping the
* recurrence columns recreate the whole `scheduled_job` table. `scheduled_task`
* references it with ON DELETE CASCADE, so the recreate's DROP would cascade and
* wipe queued tasks. Disable foreign keys for the migration so the drop stays
* local to `scheduled_job`.
*/
export class AddRecurringCronScheduleKind1784000000045 extends BaseMigration {
withFKsDisabled = true as const;
}

View File

@ -57,6 +57,7 @@ import { AddScopeColumnToOAuthTables1784000000026 } from './1784000000026-AddSco
import { AddProjectIdToInstanceAiThread1784000000028 } from './1784000000028-AddProjectIdToInstanceAiThread';
import { AllowAzureStoredAt1784000000034 } from './1784000000034-AllowAzureStoredAt';
import { DropAgentDescriptionFromAgents1784000000037 } from './1784000000037-DropAgentDescriptionFromAgents';
import { AddRecurringCronScheduleKind1784000000045 } from './1784000000045-AddRecurringCronScheduleKind';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@ -423,6 +424,7 @@ const sqliteMigrations: Migration[] = [
AddUsedPrivateCredentialsToExecutionEntity1784000000041,
CreateSchedulerTables1784000000042,
AddPartialIndexForGlobalCredentials1784000000044,
AddRecurringCronScheduleKind1784000000045,
];
export { sqliteMigrations };

View File

@ -0,0 +1,213 @@
import type { DatabaseConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { DataSource, EntityManager } from '@n8n/typeorm';
import { In } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { ScheduledJob } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import type { NewScheduledJob, ScheduledJobDefinitionUpdate } from '../scheduled-job.repository';
import { ScheduledJobRepository } from '../scheduled-job.repository';
const CLOCK = new Date('2026-01-05T09:00:00.000Z');
const newJob = (name: string): NewScheduledJob => ({
name,
workflowId: 'wf',
nodeId: 'node',
taskType: 'schedule-trigger',
payload: {},
kind: 'cron',
cronExpression: '0 0 9 * * *',
timezone: 'UTC',
recurrenceUnit: null,
recurrenceSize: null,
intervalSeconds: null,
fireAt: null,
nextRunAt: CLOCK,
});
/** A chainable insert query-builder mock; `execute` is set per test. */
const insertQb = () => ({
insert: vi.fn().mockReturnThis(),
into: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
orIgnore: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnThis(),
execute: vi.fn(),
});
describe('ScheduledJobRepository', () => {
const entityManager = mockEntityManager(ScheduledJob);
// Default DI instance resolves DatabaseConfig to its sqlite default (isPostgres = false).
const repository = Container.get(ScheduledJobRepository);
// Postgres shares the same mocked manager; only the dialect flag differs.
const postgresRepository = new ScheduledJobRepository(
entityManager.connection as unknown as DataSource,
mock<DatabaseConfig>({ type: 'postgresdb' }),
);
beforeEach(() => {
vi.resetAllMocks();
});
describe('findManyByWorkflowNode', () => {
it('returns the jobs owned by the workflow node', async () => {
const rows = [mock<ScheduledJob>({ id: 1 }), mock<ScheduledJob>({ id: 2 })];
entityManager.findBy.mockResolvedValueOnce(rows);
const result = await repository.findManyByWorkflowNode(entityManager, 'wf', 'node');
expect(entityManager.findBy).toHaveBeenCalledWith(ScheduledJob, {
workflowId: 'wf',
nodeId: 'node',
});
expect(result).toBe(rows);
});
});
describe('insertMany', () => {
it('throws when not run inside a transaction', async () => {
const noTx = mock<EntityManager>({ queryRunner: undefined });
await expect(repository.insertMany(noTx, [newJob('wf:node:0')])).rejects.toThrow(
UnexpectedError,
);
});
it('returns an empty array without touching the manager when there are no jobs', async () => {
const result = await repository.insertMany(entityManager, []);
expect(result).toEqual([]);
expect(entityManager.createQueryBuilder).not.toHaveBeenCalled();
});
it('inserts with orIgnore and reads ids back by name, in input order', async () => {
const jobs = [newJob('wf:node:0'), newJob('wf:node:1')];
const qb = insertQb();
qb.execute.mockResolvedValue(undefined);
entityManager.createQueryBuilder.mockReturnValue(qb as never);
// Read-back deliberately out of input order to prove the result is reordered.
entityManager.find.mockResolvedValueOnce([
{ id: 20, name: 'wf:node:1' } as ScheduledJob,
{ id: 10, name: 'wf:node:0' } as ScheduledJob,
]);
const ids = await repository.insertMany(entityManager, jobs);
expect(qb.values).toHaveBeenCalledWith(jobs);
expect(qb.orIgnore).toHaveBeenCalled();
expect(qb.returning).not.toHaveBeenCalled();
expect(entityManager.find).toHaveBeenCalledWith(ScheduledJob, {
where: { name: In(['wf:node:0', 'wf:node:1']) },
select: { id: true, name: true },
});
expect(ids).toEqual([10, 20]);
});
it('reads ids back by name on Postgres too, without RETURNING', async () => {
const jobs = [newJob('wf:node:0'), newJob('wf:node:1')];
const qb = insertQb();
qb.execute.mockResolvedValue(undefined);
entityManager.createQueryBuilder.mockReturnValue(qb as never);
entityManager.find.mockResolvedValueOnce([
{ id: 10, name: 'wf:node:0' } as ScheduledJob,
{ id: 20, name: 'wf:node:1' } as ScheduledJob,
]);
const ids = await postgresRepository.insertMany(entityManager, jobs);
expect(qb.returning).not.toHaveBeenCalled();
expect(entityManager.find).toHaveBeenCalledWith(ScheduledJob, {
where: { name: In(['wf:node:0', 'wf:node:1']) },
select: { id: true, name: true },
});
expect(ids).toEqual([10, 20]);
});
it('returns the id of a name a concurrent writer already held', async () => {
const jobs = [newJob('wf:node:0'), newJob('wf:node:1')];
const qb = insertQb();
qb.execute.mockResolvedValue(undefined);
entityManager.createQueryBuilder.mockReturnValue(qb as never);
// `wf:node:1` was already held by another writer; orIgnore skipped our row,
// but the read-back still finds every input name (id 99 is the other writer's).
entityManager.find.mockResolvedValueOnce([
{ id: 10, name: 'wf:node:0' } as ScheduledJob,
{ id: 99, name: 'wf:node:1' } as ScheduledJob,
]);
const ids = await repository.insertMany(entityManager, jobs);
expect(ids).toEqual([10, 99]);
});
it('throws when a name has no row after insert', async () => {
const jobs = [newJob('wf:node:0'), newJob('wf:node:1')];
const qb = insertQb();
qb.execute.mockResolvedValue(undefined);
entityManager.createQueryBuilder.mockReturnValue(qb as never);
// A name unexpectedly missing from the read-back must fail loud rather than
// return a short array that would misalign the caller's index-based zip.
entityManager.find.mockResolvedValueOnce([{ id: 10, name: 'wf:node:0' } as ScheduledJob]);
await expect(repository.insertMany(entityManager, jobs)).rejects.toThrow(UnexpectedError);
});
});
describe('updateDefinition', () => {
it('rewrites the job schedule in place, keeping its id', async () => {
const update: ScheduledJobDefinitionUpdate = {
kind: 'cron',
cronExpression: '0 0 18 * * *',
timezone: 'UTC',
recurrenceUnit: null,
recurrenceSize: null,
intervalSeconds: null,
fireAt: null,
nextRunAt: CLOCK,
};
await repository.updateDefinition(entityManager, 10, update);
expect(entityManager.update).toHaveBeenCalledWith(ScheduledJob, { id: 10 }, update);
});
});
describe('deleteManyByIds', () => {
it('deletes the given ids', async () => {
await repository.deleteManyByIds(entityManager, [1, 2]);
expect(entityManager.delete).toHaveBeenCalledWith(ScheduledJob, [1, 2]);
});
it('is a no-op when there are no ids', async () => {
await repository.deleteManyByIds(entityManager, []);
expect(entityManager.delete).not.toHaveBeenCalled();
});
});
describe('deleteByWorkflowNode', () => {
it('deletes the node jobs and returns the affected count', async () => {
entityManager.delete.mockResolvedValueOnce({ affected: 3, raw: [] });
const removed = await repository.deleteByWorkflowNode(entityManager, 'wf', 'node');
expect(entityManager.delete).toHaveBeenCalledWith(ScheduledJob, {
workflowId: 'wf',
nodeId: 'node',
});
expect(removed).toBe(3);
});
it('returns 0 when the driver does not report an affected count', async () => {
entityManager.delete.mockResolvedValueOnce({ affected: null, raw: [] });
const removed = await repository.deleteByWorkflowNode(entityManager, 'wf', 'node');
expect(removed).toBe(0);
});
});
});

View File

@ -0,0 +1,36 @@
import { Container } from '@n8n/di';
import { In } from '@n8n/typeorm';
import { ScheduledTask, ScheduledTaskStatus } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { ScheduledTaskRepository } from '../scheduled-task.repository';
describe('ScheduledTaskRepository', () => {
const entityManager = mockEntityManager(ScheduledTask);
// The constructor escapes identifiers through the driver; the mock has none, so stub it.
(entityManager.connection as unknown as { driver: { escape: (id: string) => string } }).driver = {
escape: (identifier) => `"${identifier}"`,
};
const repository = Container.get(ScheduledTaskRepository);
beforeEach(() => {
vi.resetAllMocks();
});
describe('deletePendingByJobIds', () => {
it('deletes only the pending tasks of the given jobs', async () => {
await repository.deletePendingByJobIds(entityManager, [1, 2]);
expect(entityManager.delete).toHaveBeenCalledWith(ScheduledTask, {
jobId: In([1, 2]),
status: ScheduledTaskStatus.Pending,
});
});
it('is a no-op when there are no job ids', async () => {
await repository.deletePendingByJobIds(entityManager, []);
expect(entityManager.delete).not.toHaveBeenCalled();
});
});
});

View File

@ -36,6 +36,10 @@ export { ProjectRepository, type ProjectListOptions } from './project.repository
export { RoleRepository } from './role.repository';
export { RoleMappingRuleRepository } from './role-mapping-rule.repository';
export { ScheduledJobRepository } from './scheduled-job.repository';
export type {
NewScheduledJob,
ScheduledJobDefinitionUpdate,
} from './scheduled-job.repository';
export { ScheduledTaskRepository } from './scheduled-task.repository';
export type {
ClaimDueTasksOptions,

View File

@ -1,7 +1,9 @@
import { DatabaseConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { DataSource, In, Repository } from '@n8n/typeorm';
import type { EntityManager } from '@n8n/typeorm';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import { UnexpectedError } from 'n8n-workflow';
import { ScheduledJob } from '../entities/scheduled-job';
import { dbNowLiteral, parseDbTime } from '../utils/dialect-time';
@ -13,6 +15,37 @@ export interface JobAdvance {
lastFiredAt: Date | null;
}
/** A job row to insert; bookkeeping columns take their schema defaults. */
export type NewScheduledJob = Pick<
ScheduledJob,
| 'name'
| 'workflowId'
| 'nodeId'
| 'taskType'
| 'payload'
| 'kind'
| 'cronExpression'
| 'timezone'
| 'recurrenceUnit'
| 'recurrenceSize'
| 'intervalSeconds'
| 'fireAt'
| 'nextRunAt'
>;
/** A changed schedule definition, plus the fresh clock it restarts from. */
export type ScheduledJobDefinitionUpdate = Pick<
ScheduledJob,
| 'kind'
| 'cronExpression'
| 'timezone'
| 'recurrenceUnit'
| 'recurrenceSize'
| 'intervalSeconds'
| 'fireAt'
| 'nextRunAt'
>;
@Service()
export class ScheduledJobRepository extends Repository<ScheduledJob> {
private readonly isPostgres: boolean;
@ -64,6 +97,86 @@ export class ScheduledJobRepository extends Repository<ScheduledJob> {
};
}
/** All jobs owned by one trigger node */
async findManyByWorkflowNode(
manager: EntityManager,
workflowId: string,
nodeId: string,
): Promise<ScheduledJob[]> {
return await manager.findBy(ScheduledJob, { workflowId, nodeId });
}
/**
* Insert new job rows and return one id per input job, in the same order as
* `jobs`, so the caller can zip the ids back to the jobs by index.
* Must run inside a transaction.
*
* `orIgnore` emits `ON CONFLICT DO NOTHING` (Postgres) / `INSERT OR IGNORE`
* (SQLite): a name already taken is left as-is rather than erroring, so two mains
* activating the same node at once converge on one set of rows (the first
* writer's, clock intact) instead of the second failing on the unique index.
*
* The ids are read back by name rather than from `RETURNING`: `RETURNING` omits
* the rows `orIgnore` skipped, so a name a concurrent writer already inserted
* would come back without an id. `name` is unique and every input job has a row
* once the insert returns (ours, or the concurrent writer's), so the read-back
* yields exactly one id per job.
*/
async insertMany(manager: EntityManager, jobs: NewScheduledJob[]): Promise<number[]> {
if (manager.queryRunner === undefined) {
throw new UnexpectedError('insertMany must run within a transaction');
}
if (jobs.length === 0) {
return [];
}
// `payload` is a free-form JSON column, which TypeORM's QueryDeepPartialEntity can't express,
// so the well-typed rows are cast at this boundary.
await manager
.createQueryBuilder()
.insert()
.into(ScheduledJob)
.values(jobs as Array<QueryDeepPartialEntity<ScheduledJob>>)
.orIgnore()
.execute();
const rows = await manager.find(ScheduledJob, {
where: { name: In(jobs.map((job) => job.name)) },
select: { id: true, name: true },
});
return orderIdsByName(rows, jobs);
}
/**
* Rewrite a job's schedule in place,
* keeping its id so queued tasks stay attributable.
*/
async updateDefinition(
manager: EntityManager,
id: number,
update: ScheduledJobDefinitionUpdate,
): Promise<void> {
await manager.update(ScheduledJob, { id }, update);
}
async deleteManyByIds(manager: EntityManager, ids: number[]): Promise<void> {
if (ids.length > 0) {
await manager.delete(ScheduledJob, ids);
}
}
/**
* Delete all jobs owned by one trigger node; their tasks cascade away.
* @returns how many jobs were deleted (0 when the driver can't report it).
*/
async deleteByWorkflowNode(
manager: EntityManager,
workflowId: string,
nodeId: string,
): Promise<number> {
const result = await manager.delete(ScheduledJob, { workflowId, nodeId });
return result.affected ?? 0;
}
/**
* Advance many jobs' clocks, a statement per chunk.
* Callers pass distinct ids (a batch of claimed jobs).
@ -107,3 +220,22 @@ export class ScheduledJobRepository extends Repository<ScheduledJob> {
.execute();
}
}
/**
* Ids for `jobs`, in input order, from rows carrying their `{ id, name }`.
* Throws on a name with no row: the caller zips the result back to `jobs` by
* index, so a gap would misalign every id after it.
*/
function orderIdsByName(
rows: Array<{ id: number; name: string }>,
jobs: NewScheduledJob[],
): number[] {
const idByName = new Map(rows.map((row) => [row.name, row.id]));
return jobs.map((job) => {
const id = idByName.get(job.name);
if (id === undefined) {
throw new UnexpectedError(`No row found for scheduled job "${job.name}" after insert`);
}
return id;
});
}

View File

@ -69,6 +69,24 @@ export interface NewOccurrence {
maxAttempts: number;
}
/** Identity of a row {@link ScheduledTaskRepository.insertIgnoringDuplicates} just created. */
export interface RecordedOccurrence {
id: string;
jobId: number;
taskType: string;
}
export interface InsertOccurrencesResult {
/** How many rows were actually inserted (skipped duplicates excluded). */
recorded: number;
/**
* The newly inserted rows' identity, for per-row tracing. Only Postgres can
* return this (`RETURNING`); elsewhere it is empty even though `recorded` is
* still accurate.
*/
created: RecordedOccurrence[];
}
/**
* Point-in-time queue health, read at Prometheus scrape time
* (see {@link ScheduledTaskRepository.getMetricSnapshot}).
@ -110,7 +128,8 @@ export class ScheduledTaskRepository extends Repository<ScheduledTask> {
*
* Must run inside a transaction!
*
* @returns how many rows were actually inserted (skipped duplicates excluded)
* @returns how many rows were recorded, and (Postgres only) the identity of
* each newly created row, for per-row tracing
*/
async insertIgnoringDuplicates(
manager: EntityManager,
@ -118,7 +137,7 @@ export class ScheduledTaskRepository extends Repository<ScheduledTask> {
// Chunked so a large batch stays under the driver's bind-parameter ceiling
// (Postgres 65535, SQLite 32766); the default leaves headroom for the widest row.
chunkSize = 1000,
): Promise<number> {
): Promise<InsertOccurrencesResult> {
const { queryRunner } = manager;
if (queryRunner === undefined) {
throw new UnexpectedError('insertIgnoringDuplicates must run within a transaction');
@ -129,20 +148,51 @@ export class ScheduledTaskRepository extends Repository<ScheduledTask> {
);
let recorded = 0;
const created: RecordedOccurrence[] = [];
for (const chunk of chunks) {
const [sql, parameters] = manager
const query = manager
.createQueryBuilder()
.insert()
.into(ScheduledTask)
// `payload` is a free-form JSON column, which TypeORM's QueryDeepPartialEntity
// can't express, so the well-typed rows are cast at this boundary.
.values(chunk as Array<QueryDeepPartialEntity<ScheduledTask>>)
.orIgnore()
.getQueryAndParameters();
.orIgnore();
// Only Postgres's driver surfaces RETURNING rows from a raw query
// (SQLite's `run()` callback gives back a change count, never rows), so row
// identity for tracing is a Postgres-only capability; `recorded` stays accurate
// everywhere via `affected`.
if (this.isPostgres) {
query.returning('"id", "jobId", "taskType"');
}
const [sql, parameters] = query.getQueryAndParameters();
const result = await queryRunner.query(sql, parameters, true);
recorded += result.affected ?? 0;
if (this.isPostgres) {
for (const row of (result.records ?? []) as Array<{
id: string | number;
jobId: number;
taskType: string;
}>) {
created.push({ id: String(row.id), jobId: row.jobId, taskType: row.taskType });
}
}
}
return { recorded, created };
}
/**
* Delete the still-pending occurrences of the given job ids.
*/
async deletePendingByJobIds(manager: EntityManager, jobIds: number[]): Promise<void> {
if (jobIds.length > 0) {
await manager.delete(ScheduledTask, {
jobId: In(jobIds),
status: ScheduledTaskStatus.Pending,
});
}
return recorded;
}
/**

View File

@ -44,57 +44,44 @@ export default [
💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).\
❌ Deprecated.
| Name                                  | Description | 💼 | ⚠️ | 🔧 | 💡 | ❌ |
| :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--- | :--- | :- | :- | :- |
| [ai-node-package-json](docs/rules/ai-node-package-json.md) | Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages | ✅ ☑️ | | | | |
| [cred-class-field-icon-missing](docs/rules/cred-class-field-icon-missing.md) | Credential class must have an `icon` property defined | ✅ ☑️ | | | 💡 | |
| [cred-class-name-field-conventions](docs/rules/cred-class-name-field-conventions.md) | Credential `name` field must end with `Api` and start with a lowercase letter | ✅ ☑️ | | 🔧 | | |
| [cred-class-name-suffix](docs/rules/cred-class-name-suffix.md) | Credential class names must be suffixed with `Api` | ✅ ☑️ | | 🔧 | | |
| [cred-class-oauth2-naming](docs/rules/cred-class-oauth2-naming.md) | OAuth2 credentials must include `OAuth2` in the class name, `name`, and `displayName` | ✅ ☑️ | | 🔧 | | |
| [cred-filename-against-convention](docs/rules/cred-filename-against-convention.md) | Credential filename must match the credential class name | ✅ ☑️ | | | | |
| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) | ✅ ☑️ | | 🔧 | | |
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | |
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 | |
| [icon-prefer-themed-variants](docs/rules/icon-prefer-themed-variants.md) | Encourage node and credential icons to provide light/dark variants instead of a single icon file | | ✅ ☑️ | | | |
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, use the file: protocol, and that light/dark icons are different | ✅ ☑️ | | | 💡 | |
| [missing-paired-item](docs/rules/missing-paired-item.md) | Require pairedItem on INodeExecutionData objects in execute() methods to preserve item linking. | ✅ ☑️ | | | | |
| [n8n-object-validation](docs/rules/n8n-object-validation.md) | Validate the structure of the "n8n" object in community node package.json (required keys, types, and dist/ paths) | ✅ ☑️ | | | | |
| [no-asterisk-in-option-names](docs/rules/no-asterisk-in-option-names.md) | Disallow asterisk characters in node option name values | | ✅ ☑️ | | 💡 | |
| [no-builder-hint-leakage](docs/rules/no-builder-hint-leakage.md) | Disallow wire-format expression syntax (={{...}}) and NodeConnectionType string literals in builderHint texts and AI-builder prompts. Use expr() and SDK-canonical references instead. | ✅ ☑️ | | | | |
| [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 | |
| [no-dangerous-functions](docs/rules/no-dangerous-functions.md) | Disallow `eval`, the `Function` constructor, and `child_process` process-spawning functions (`exec`, `spawn`, etc.) in community nodes. | ✅ ☑️ | | | | |
| [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 | |
| [no-duplicate-param-options](docs/rules/no-duplicate-param-options.md) | Disallow duplicate option names or values within a single node parameter | | ✅ ☑️ | | | |
| [no-emoji-in-options](docs/rules/no-emoji-in-options.md) | Disallow emoji characters in node option name and displayName values | ✅ ☑️ | | | | |
| [no-forbidden-lifecycle-scripts](docs/rules/no-forbidden-lifecycle-scripts.md) | Ban lifecycle scripts (prepare, preinstall, postinstall, etc.) in community node packages | ✅ ☑️ | | | | |
| [no-http-request-with-manual-auth](docs/rules/no-http-request-with-manual-auth.md) | Disallow this.helpers.httpRequest() in functions that call this.getCredentials(). Use this.helpers.httpRequestWithAuthentication() instead. | ✅ ☑️ | | | | |
| [no-overrides-field](docs/rules/no-overrides-field.md) | Ban the "overrides" field in community node package.json | ✅ ☑️ | | | | |
| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | | |
| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | | |
| [no-runtime-dependencies](docs/rules/no-runtime-dependencies.md) | Disallow non-empty "dependencies" in community node package.json | ✅ ☑️ | | | | |
| [no-template-placeholders](docs/rules/no-template-placeholders.md) | Disallow unresolved template placeholders in package.json | ✅ ☑️ | | | | |
| [node-class-description-icon-missing](docs/rules/node-class-description-icon-missing.md) | Node class description must have an `icon` property defined. Deprecated: use `require-node-description-fields` instead. | | | | 💡 | ❌ |
| [node-class-description-name-camelcase](docs/rules/node-class-description-name-camelcase.md) | Node class `description.name` must be camelCase | ✅ ☑️ | | 🔧 | | |
| [node-connection-type-literal](docs/rules/node-connection-type-literal.md) | Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead | ✅ ☑️ | | 🔧 | | |
| [node-filename-against-convention](docs/rules/node-filename-against-convention.md) | Node filename must match the node `description.name` | ✅ ☑️ | | | | |
| [node-operation-error-itemindex](docs/rules/node-operation-error-itemindex.md) | Require { itemIndex } in NodeOperationError / NodeApiError options inside item loops | ✅ ☑️ | | | | |
| [node-registration-complete](docs/rules/node-registration-complete.md) | Ensure every `.node.ts` file in the `nodes/` directory is registered in the "n8n.nodes" array of package.json | | ✅ ☑️ | | | |
| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | | |
| [options-sorted-alphabetically](docs/rules/options-sorted-alphabetically.md) | Enforce alphabetical ordering of options arrays in n8n node properties | | ✅ ☑️ | | | |
| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 | |
| [require-community-node-keyword](docs/rules/require-community-node-keyword.md) | Require the "n8n-community-node-package" keyword in community node package.json | | ✅ ☑️ | 🔧 | | |
| [require-continue-on-fail](docs/rules/require-continue-on-fail.md) | Require continueOnFail() handling in execute() methods of node classes | ✅ ☑️ | | | | |
| [require-mit-license](docs/rules/require-mit-license.md) | Require the "license" field in community node package.json to be "MIT" | ✅ ☑️ | | 🔧 | | |
| [require-node-api-error](docs/rules/require-node-api-error.md) | Require NodeApiError or NodeOperationError for error wrapping in catch blocks. Raw errors lose HTTP context in the n8n UI. | ✅ ☑️ | | | | |
| [require-node-description-fields](docs/rules/require-node-description-fields.md) | Node class description must define all required fields: icon, subtitle | ✅ ☑️ | | | | |
| [require-param-default](docs/rules/require-param-default.md) | Require every node parameter to declare a default value. | ✅ ☑️ | | | | |
| [require-version](docs/rules/require-version.md) | Require a valid "version" field in community node package.json | ✅ ☑️ | | | | |
| [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | | |
| [trigger-node-conventions](docs/rules/trigger-node-conventions.md) | Trigger nodes (class name ends with `Trigger`) must label themselves consistently as triggers | ✅ ☑️ | | | | |
| [valid-author](docs/rules/valid-author.md) | Require a non-empty author name and email in package.json | ✅ ☑️ | | | | |
| [valid-credential-references](docs/rules/valid-credential-references.md) | Ensure credentials referenced in node descriptions exist as credential classes in the package | ✅ ☑️ | | | 💡 | |
| [valid-description](docs/rules/valid-description.md) | Require a non-empty "description" field in community node package.json | ✅ ☑️ | | | | |
| [valid-peer-dependencies](docs/rules/valid-peer-dependencies.md) | Require community node package.json peerDependencies to contain only "n8n-workflow": "*" (and optionally "@n8n/ai-node-sdk") | ✅ ☑️ | | 🔧 | | |
| [webhook-lifecycle-complete](docs/rules/webhook-lifecycle-complete.md) | Require webhook trigger nodes to implement the complete webhookMethods lifecycle (checkExists, create, delete) | ✅ ☑️ | | | | |
| Name                                | Description | 💼 | ⚠️ | 🔧 | 💡 | ❌ |
| :--------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--- | :--- | :- | :- | :- |
| [ai-node-package-json](docs/rules/ai-node-package-json.md) | Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages | ✅ ☑️ | | | | |
| [cred-class-field-icon-missing](docs/rules/cred-class-field-icon-missing.md) | Credential class must have an `icon` property defined | ✅ ☑️ | | | 💡 | |
| [cred-class-oauth2-naming](docs/rules/cred-class-oauth2-naming.md) | OAuth2 credentials must include `OAuth2` in the class name, `name`, and `displayName` | ✅ ☑️ | | 🔧 | | |
| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) | ✅ ☑️ | | 🔧 | | |
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | |
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 | |
| [icon-prefer-themed-variants](docs/rules/icon-prefer-themed-variants.md) | Encourage node and credential icons to provide light/dark variants instead of a single icon file | | ✅ ☑️ | | | |
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, use the file: protocol, and that light/dark icons are different | ✅ ☑️ | | | 💡 | |
| [missing-paired-item](docs/rules/missing-paired-item.md) | Require pairedItem on INodeExecutionData objects in execute() methods to preserve item linking. | ✅ ☑️ | | | | |
| [n8n-object-validation](docs/rules/n8n-object-validation.md) | Validate the structure of the "n8n" object in community node package.json (required keys, types, and dist/ paths) | ✅ ☑️ | | | | |
| [no-builder-hint-leakage](docs/rules/no-builder-hint-leakage.md) | Disallow wire-format expression syntax (={{...}}) and NodeConnectionType string literals in builderHint texts and AI-builder prompts. Use expr() and SDK-canonical references instead. | ✅ ☑️ | | | | |
| [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 | |
| [no-dangerous-functions](docs/rules/no-dangerous-functions.md) | Disallow `eval`, the `Function` constructor, and `child_process` process-spawning functions (`exec`, `spawn`, etc.) in community nodes. | ✅ ☑️ | | | | |
| [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 | |
| [no-emoji-in-options](docs/rules/no-emoji-in-options.md) | Disallow emoji characters in node option name and displayName values | ✅ ☑️ | | | | |
| [no-forbidden-lifecycle-scripts](docs/rules/no-forbidden-lifecycle-scripts.md) | Ban lifecycle scripts (prepare, preinstall, postinstall, etc.) in community node packages | ✅ ☑️ | | | | |
| [no-http-request-with-manual-auth](docs/rules/no-http-request-with-manual-auth.md) | Disallow this.helpers.httpRequest() in functions that call this.getCredentials(). Use this.helpers.httpRequestWithAuthentication() instead. | ✅ ☑️ | | | | |
| [no-overrides-field](docs/rules/no-overrides-field.md) | Ban the "overrides" field in community node package.json | ✅ ☑️ | | | | |
| [no-restricted-globals](docs/rules/no-restricted-globals.md) | Disallow usage of restricted global variables in community nodes. | ✅ | | | | |
| [no-restricted-imports](docs/rules/no-restricted-imports.md) | Disallow usage of restricted imports in community nodes. | ✅ | | | | |
| [no-runtime-dependencies](docs/rules/no-runtime-dependencies.md) | Disallow non-empty "dependencies" in community node package.json | ✅ ☑️ | | | | |
| [no-template-placeholders](docs/rules/no-template-placeholders.md) | Disallow unresolved template placeholders in package.json | ✅ ☑️ | | | | |
| [node-class-description-icon-missing](docs/rules/node-class-description-icon-missing.md) | Node class description must have an `icon` property defined. Deprecated: use `require-node-description-fields` instead. | | | | 💡 | ❌ |
| [node-connection-type-literal](docs/rules/node-connection-type-literal.md) | Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead | ✅ ☑️ | | 🔧 | | |
| [node-registration-complete](docs/rules/node-registration-complete.md) | Ensure every `.node.ts` file in the `nodes/` directory is registered in the "n8n.nodes" array of package.json | | ✅ ☑️ | | | |
| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | | |
| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 | |
| [require-node-api-error](docs/rules/require-node-api-error.md) | Require NodeApiError or NodeOperationError for error wrapping in catch blocks. Raw errors lose HTTP context in the n8n UI. | ✅ ☑️ | | | | |
| [require-node-description-fields](docs/rules/require-node-description-fields.md) | Node class description must define all required fields: icon, subtitle | ✅ ☑️ | | | | |
| [require-version](docs/rules/require-version.md) | Require a valid "version" field in community node package.json | ✅ ☑️ | | | | |
| [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | | |
| [trigger-node-conventions](docs/rules/trigger-node-conventions.md) | Trigger nodes (class name ends with `Trigger`) must label themselves consistently as triggers | ✅ ☑️ | | | | |
| [valid-author](docs/rules/valid-author.md) | Require a non-empty author name and email in package.json | ✅ ☑️ | | | | |
| [valid-credential-references](docs/rules/valid-credential-references.md) | Ensure credentials referenced in node descriptions exist as credential classes in the package | ✅ ☑️ | | | 💡 | |
| [valid-description](docs/rules/valid-description.md) | Require a non-empty "description" field in community node package.json | ✅ ☑️ | | | | |
| [valid-peer-dependencies](docs/rules/valid-peer-dependencies.md) | Require community node package.json peerDependencies to contain only "n8n-workflow": "*" (and optionally "@n8n/ai-node-sdk") | ✅ ☑️ | | 🔧 | | |
| [webhook-lifecycle-complete](docs/rules/webhook-lifecycle-complete.md) | Require webhook trigger nodes to implement the complete webhookMethods lifecycle (checkExists, create, delete) | ✅ ☑️ | | | | |
<!-- end auto-generated rules list -->

View File

@ -1,38 +0,0 @@
# Credential `name` field must end with `Api` and start with a lowercase letter (`@n8n/community-nodes/cred-class-name-field-conventions`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
<!-- end auto-generated rule header -->
## Rule Details
The `name` field of a credential class (those implementing `ICredentialType` in `*.credentials.ts` files) is the internal identifier referenced by nodes and stored in the credential registry. n8n convention requires this identifier to:
- End with `Api` (e.g. `githubApi`), so credentials are easily recognisable across the codebase.
- Start with a lowercase letter (camelCase), since it is an identifier value rather than a class name.
Both checks are automatically fixable.
## Examples
### ❌ Incorrect
```typescript
export class GithubApi implements ICredentialType {
name = 'Github';
displayName = 'GitHub API';
properties: INodeProperties[] = [];
}
```
### ✅ Correct
```typescript
export class GithubApi implements ICredentialType {
name = 'githubApi';
displayName = 'GitHub API';
properties: INodeProperties[] = [];
}
```

View File

@ -1,46 +0,0 @@
# Credential class names must be suffixed with `Api` (`@n8n/community-nodes/cred-class-name-suffix`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
<!-- end auto-generated rule header -->
## Rule Details
Credential classes (those implementing `ICredentialType` in `*.credentials.ts` files) must have a class name ending in `Api`. This is the n8n convention so credentials are easily recognisable in code, in import statements, and across the credential registry.
For OAuth2 credentials extending the OAuth2 base, see the sibling rule `cred-class-oauth2-naming` which enforces the more specific `OAuth2Api` suffix.
## Opt-out
For legitimate exceptions (for example credentials representing custom auth headers where the `Api` suffix would be misleading), disable the rule for the specific class with a standard ESLint comment:
```typescript
// eslint-disable-next-line @n8n/community-nodes/cred-class-name-suffix
export class CustomAuthHeader implements ICredentialType {
// ...
}
```
## Examples
### ❌ Incorrect
```typescript
export class MyService implements ICredentialType {
name = 'myServiceApi';
displayName = 'My Service API';
properties: INodeProperties[] = [];
}
```
### ✅ Correct
```typescript
export class MyServiceApi implements ICredentialType {
name = 'myServiceApi';
displayName = 'My Service API';
properties: INodeProperties[] = [];
}
```

View File

@ -1,42 +0,0 @@
# Credential filename must match the credential class name (`@n8n/community-nodes/cred-filename-against-convention`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
This rule enforces that the basename of a `*.credentials.ts` file (without the
`.credentials.ts` extension) matches the name of the credential class it
contains. The class name is the source of truth.
Keeping filenames in sync with class names makes credentials predictable to
locate and matches the convention used throughout n8n's built-in credentials.
## Examples
### Incorrect
```typescript
// File: GithubApi.credentials.ts
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class GitlabApi implements ICredentialType {
name = 'gitlabApi';
displayName = 'GitLab API';
properties: INodeProperties[] = [];
}
```
### Correct
```typescript
// File: GithubApi.credentials.ts
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class GithubApi implements ICredentialType {
name = 'githubApi';
displayName = 'GitHub API';
properties: INodeProperties[] = [];
}
```

View File

@ -1,61 +0,0 @@
# Disallow asterisk characters in node option name values (`@n8n/community-nodes/no-asterisk-in-option-names`)
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
<!-- end auto-generated rule header -->
## Rule Details
The `name` of each entry in an `options` array is rendered directly in the n8n
editor. An asterisk (`*`) in these labels renders ambiguously — it reads like a
markdown bullet or emphasis marker rather than literal text. This rule flags any
option `name` containing `*` and suggests replacing it with bracketed notation
such as `[All]`.
The rule only inspects `name` values inside `options` arrays (including nested
`collection` and `fixedCollection` options); asterisks elsewhere are not
reported.
## Examples
### Incorrect
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [{ name: '* All', value: 'all' }],
default: 'all',
},
],
};
}
```
### Correct
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [{ name: '[All]', value: 'all' }],
default: 'all',
},
],
};
}
```

View File

@ -1,46 +0,0 @@
# Disallow duplicate option names or values within a single node parameter (`@n8n/community-nodes/no-duplicate-param-options`)
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
Within an `options`- or `multiOptions`-typed node parameter, each option must
have a unique `name` and a unique `value`. Duplicate names confuse users in the
dropdown, and duplicate values make selections ambiguous and break value-based
logic downstream.
## Examples
### Incorrect
```typescript
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'Contact', value: 'contact' },
{ name: 'Contact', value: 'person' }, // duplicate name
{ name: 'User', value: 'contact' }, // duplicate value
],
default: 'contact',
}
```
### Correct
```typescript
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'Contact', value: 'contact' },
{ name: 'Person', value: 'person' },
{ name: 'User', value: 'user' },
],
default: 'contact',
}
```

View File

@ -1,41 +0,0 @@
# Node class `description.name` must be camelCase (`@n8n/community-nodes/node-class-description-name-camelcase`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
<!-- end auto-generated rule header -->
## Rule Details
The `name` field inside a node class `description` (those implementing `INodeType` or extending `Node` in `*.node.ts` files) is the internal identifier used to register and reference the node. n8n convention requires this identifier to be camelCase: it must start with a lowercase letter and contain only letters and digits, with no spaces, hyphens, underscores, or other separators.
The value must match `^[a-z][a-zA-Z0-9]*$`.
The rule is automatically fixable: separators are removed, each subsequent word is upper-cased, and the first character is lower-cased (e.g. `My Node` → `myNode`). Names that cannot be repaired into a valid identifier (for example, those starting with a digit) are reported without an autofix.
## Examples
### ❌ Incorrect
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'My Node',
// ...
};
}
```
### ✅ Correct
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
// ...
};
}
```

View File

@ -1,50 +0,0 @@
# Node filename must match the node `description.name` (`@n8n/community-nodes/node-filename-against-convention`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
Every `*.node.ts` file must be named after its node's `description.name`, with the
first character upper-cased. The source of truth is `description.name` — **not**
the class name. Keeping the filename in sync with the registered node name makes
nodes predictable to locate and matches the convention n8n tooling expects.
A trailing version suffix (`V2`, `V3`, …) is allowed, so versioned implementations
such as `GithubV2.node.ts` are accepted for a node whose `description.name` is
`github`.
The comparison is case-sensitive: a node named `github` must live in
`Github.node.ts`, not `github.node.ts` or `GitHub.node.ts`.
This rule is not auto-fixable because ESLint cannot rename files. Rename the file
to the reported name.
## Examples
### ❌ Incorrect
```typescript
// File: Gitlab.node.ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub',
name: 'github', // expected file to be named "Github.node.ts"
// ...
};
}
```
### ✅ Correct
```typescript
// File: Github.node.ts (or GithubV2.node.ts)
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub',
name: 'github',
// ...
};
}
```

View File

@ -1,81 +0,0 @@
# Require { itemIndex } in NodeOperationError / NodeApiError options inside item loops (`@n8n/community-nodes/node-operation-error-itemindex`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
When throwing `NodeOperationError` or `NodeApiError` inside the item-processing loop of an `execute()` method, the options object (third argument) must contain an `itemIndex` property. Without it, n8n cannot associate the error with the specific item that caused it, which breaks per-item error reporting and `continueOnFail` behaviour.
The rule only fires inside **item loops** — `for` or `for...of` statements that iterate over the result of `this.getInputData()`. Errors thrown outside such loops (e.g. in webhook handlers, trigger setup, or credential testing helpers) are not flagged.
## Examples
### ❌ Incorrect
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = { /* ... */ };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
// ...
} catch (error) {
// Missing { itemIndex } — n8n cannot map this error back to item i
throw new NodeOperationError(this.getNode(), error);
}
}
return [returnData];
}
}
```
### ✅ Correct
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = { /* ... */ };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
// ...
} catch (error) {
throw new NodeOperationError(this.getNode(), error, { itemIndex: i });
}
}
return [returnData];
}
}
```
Using `for...of` with a named loop variable:
```typescript
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
let itemIndex = 0;
for (const item of items) {
try {
// ...
} catch (error) {
throw new NodeApiError(this.getNode(), error, { itemIndex });
}
itemIndex++;
}
return [returnData];
}
```

View File

@ -1,63 +0,0 @@
# Enforce alphabetical ordering of options arrays in n8n node properties (`@n8n/community-nodes/options-sorted-alphabetically`)
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
Warns when an `options`-type parameter has its options array not sorted alphabetically by name. Applies to all `type: 'options'` parameters — including `resource`, `operation`, and any other dropdowns.
Alphabetical ordering is an [official n8n UI design requirement](https://docs.n8n.io/integrations/creating-nodes/plan/node-ui-design/#lists) and the most frequently flagged issue in community node reviews.
Comparison is case-insensitive and locale-aware (handles non-ASCII names such as Spanish or Portuguese labels).
## Examples
### ❌ Incorrect
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Service',
name: 'myService',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
{ name: 'Contact', value: 'contact' }, // out of order
{ name: 'Project', value: 'project' },
],
default: 'user',
},
],
};
}
```
### ✅ Correct
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Service',
name: 'myService',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'Contact', value: 'contact' },
{ name: 'Project', value: 'project' },
{ name: 'User', value: 'user' },
],
default: 'contact',
},
],
};
}
```

View File

@ -1,45 +0,0 @@
# Require the "n8n-community-node-package" keyword in community node package.json (`@n8n/community-nodes/require-community-node-keyword`)
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
<!-- end auto-generated rule header -->
## Rule Details
Validates that the `package.json` of a community node package includes `"n8n-community-node-package"` in its `keywords` array. This keyword is required for n8n to discover and list community node packages.
## Examples
### ❌ Incorrect
```json
{
"name": "n8n-nodes-my-service",
"version": "1.0.0"
}
```
```json
{
"name": "n8n-nodes-my-service",
"keywords": ["n8n", "automation"]
}
```
### ✅ Correct
```json
{
"name": "n8n-nodes-my-service",
"keywords": ["n8n-community-node-package"]
}
```
```json
{
"name": "n8n-nodes-my-service",
"keywords": ["n8n", "automation", "n8n-community-node-package"]
}
```

View File

@ -1,56 +0,0 @@
# Require continueOnFail() handling in execute() methods of node classes (`@n8n/community-nodes/require-continue-on-fail`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
Ensures that `execute()` methods in node classes include a `this.continueOnFail()` check. Without this, a single item error will abort the entire workflow instead of allowing execution to continue past the failing item.
## Examples
### ❌ Incorrect
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = { /* ... */ };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
// No error handling — one bad item kills the whole workflow
const result = await someApiCall(items[i]);
returnData.push({ json: result });
}
return [returnData];
}
}
```
### ✅ Correct
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = { /* ... */ };
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const result = await someApiCall(items[i]);
returnData.push({ json: result });
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
continue;
}
throw error;
}
}
return [returnData];
}
}
```

View File

@ -1,38 +0,0 @@
# Require the "license" field in community node package.json to be "MIT" (`@n8n/community-nodes/require-mit-license`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
<!-- end auto-generated rule header -->
## Rule Details
Validates that the `package.json` of a community node package declares its `license` as `"MIT"`. Community node packages must be MIT licensed to be distributed through n8n.
## Examples
### ❌ Incorrect
```json
{
"name": "n8n-nodes-my-service",
"version": "1.0.0"
}
```
```json
{
"name": "n8n-nodes-my-service",
"license": "Apache-2.0"
}
```
### ✅ Correct
```json
{
"name": "n8n-nodes-my-service",
"license": "MIT"
}
```

View File

@ -1,60 +0,0 @@
# Require every node parameter to declare a default value (`@n8n/community-nodes/require-param-default`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
Every node parameter must declare a `default` property. A parameter is detected
by its shape: an object inside the node's `description` with `displayName`,
`name`, and `type` all set to string literals — the shape every
`INodeProperties` entry has. Entries in an `options` array (which carry
`name`/`value` but no `displayName`/`type`) are not treated as parameters.
Without a `default`, n8n cannot reliably initialise the parameter's value. This
leads to inconsistent behaviour in the editor (empty or `undefined` fields) and
at execution time. Even non-input types such as `notice` are expected to declare
`default: ''`.
This rule also checks parameters nested inside `options` of `collection` and
`fixedCollection` parameters.
## Examples
### Incorrect
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
properties: [
{
displayName: 'Field',
name: 'field',
type: 'string',
},
],
};
}
```
### Correct
```typescript
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
properties: [
{
displayName: 'Field',
name: 'field',
type: 'string',
default: '',
},
],
};
}
```

View File

@ -27,8 +27,6 @@ const configs = {
'@n8n/community-nodes/n8n-object-validation': 'error',
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
'@n8n/community-nodes/no-emoji-in-options': 'error',
'@n8n/community-nodes/no-asterisk-in-option-names': 'warn',
'@n8n/community-nodes/no-duplicate-param-options': 'warn',
'@n8n/community-nodes/node-usable-as-tool': 'error',
'@n8n/community-nodes/package-name-convention': 'error',
'@n8n/community-nodes/credential-test-required': 'error',
@ -41,28 +39,17 @@ const configs = {
'@n8n/community-nodes/no-template-placeholders': 'error',
'@n8n/community-nodes/icon-validation': 'error',
'@n8n/community-nodes/icon-prefer-themed-variants': 'warn',
'@n8n/community-nodes/options-sorted-alphabetically': 'warn',
'@n8n/community-nodes/resource-operation-pattern': 'warn',
'@n8n/community-nodes/trigger-node-conventions': 'error',
'@n8n/community-nodes/credential-documentation-url': 'error',
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
'@n8n/community-nodes/cred-class-name-field-conventions': 'error',
'@n8n/community-nodes/cred-class-name-suffix': 'error',
'@n8n/community-nodes/cred-class-oauth2-naming': 'error',
'@n8n/community-nodes/cred-filename-against-convention': 'error',
'@n8n/community-nodes/node-connection-type-literal': 'error',
'@n8n/community-nodes/node-class-description-name-camelcase': 'error',
'@n8n/community-nodes/node-filename-against-convention': 'error',
'@n8n/community-nodes/missing-paired-item': 'error',
'@n8n/community-nodes/no-builder-hint-leakage': 'error',
'@n8n/community-nodes/node-operation-error-itemindex': 'error',
'@n8n/community-nodes/node-registration-complete': 'warn',
'@n8n/community-nodes/require-community-node-keyword': 'warn',
'@n8n/community-nodes/require-continue-on-fail': 'error',
'@n8n/community-nodes/require-mit-license': 'error',
'@n8n/community-nodes/require-node-api-error': 'error',
'@n8n/community-nodes/require-node-description-fields': 'error',
'@n8n/community-nodes/require-param-default': 'error',
'@n8n/community-nodes/require-version': 'error',
'@n8n/community-nodes/valid-author': 'error',
'@n8n/community-nodes/valid-credential-references': 'error',
@ -82,8 +69,6 @@ const configs = {
'@n8n/community-nodes/n8n-object-validation': 'error',
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
'@n8n/community-nodes/no-emoji-in-options': 'error',
'@n8n/community-nodes/no-asterisk-in-option-names': 'warn',
'@n8n/community-nodes/no-duplicate-param-options': 'warn',
'@n8n/community-nodes/node-usable-as-tool': 'error',
'@n8n/community-nodes/package-name-convention': 'error',
'@n8n/community-nodes/credential-test-required': 'error',
@ -96,28 +81,17 @@ const configs = {
'@n8n/community-nodes/no-template-placeholders': 'error',
'@n8n/community-nodes/icon-validation': 'error',
'@n8n/community-nodes/icon-prefer-themed-variants': 'warn',
'@n8n/community-nodes/options-sorted-alphabetically': 'warn',
'@n8n/community-nodes/credential-documentation-url': 'error',
'@n8n/community-nodes/resource-operation-pattern': 'warn',
'@n8n/community-nodes/trigger-node-conventions': 'error',
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
'@n8n/community-nodes/cred-class-name-field-conventions': 'error',
'@n8n/community-nodes/cred-class-name-suffix': 'error',
'@n8n/community-nodes/cred-class-oauth2-naming': 'error',
'@n8n/community-nodes/cred-filename-against-convention': 'error',
'@n8n/community-nodes/node-connection-type-literal': 'error',
'@n8n/community-nodes/node-class-description-name-camelcase': 'error',
'@n8n/community-nodes/node-filename-against-convention': 'error',
'@n8n/community-nodes/missing-paired-item': 'error',
'@n8n/community-nodes/no-builder-hint-leakage': 'error',
'@n8n/community-nodes/node-operation-error-itemindex': 'error',
'@n8n/community-nodes/node-registration-complete': 'warn',
'@n8n/community-nodes/require-community-node-keyword': 'warn',
'@n8n/community-nodes/require-continue-on-fail': 'error',
'@n8n/community-nodes/require-mit-license': 'error',
'@n8n/community-nodes/require-node-api-error': 'error',
'@n8n/community-nodes/require-node-description-fields': 'error',
'@n8n/community-nodes/require-param-default': 'error',
'@n8n/community-nodes/require-version': 'error',
'@n8n/community-nodes/valid-author': 'error',
'@n8n/community-nodes/valid-credential-references': 'error',

View File

@ -1,121 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { CredClassNameFieldConventionsRule } from './cred-class-name-field-conventions.js';
const ruleTester = new RuleTester();
const credFilePath = '/tmp/TestCredential.credentials.ts';
const nonCredFilePath = '/tmp/SomeHelper.ts';
function createCredentialCode(name: string): string {
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class TestApi implements ICredentialType {
name = '${name}';
displayName = 'Test API';
properties: INodeProperties[] = [];
}`;
}
function createRegularClass(name: string): string {
return `
export class SomeHelper {
name = '${name}';
}`;
}
// Embeds the raw literal text (including its quotes) verbatim, so tests can
// exercise names containing quote characters that need escaping.
function createCredentialCodeWithLiteral(literal: string): string {
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class TestApi implements ICredentialType {
name = ${literal};
displayName = 'Test API';
properties: INodeProperties[] = [];
}`;
}
ruleTester.run('cred-class-name-field-conventions', CredClassNameFieldConventionsRule, {
valid: [
{
name: 'name field with Api suffix and lowercase first char',
filename: credFilePath,
code: createCredentialCode('githubApi'),
},
{
name: 'OAuth2 name field is also valid',
filename: credFilePath,
code: createCredentialCode('githubOAuth2Api'),
},
{
name: 'class not implementing ICredentialType is ignored',
filename: credFilePath,
code: createRegularClass('Github'),
},
{
name: 'non-.credentials.ts file is ignored',
filename: nonCredFilePath,
code: createCredentialCode('Github'),
},
],
invalid: [
{
name: 'name field missing Api suffix',
filename: credFilePath,
code: createCredentialCode('github'),
errors: [{ messageId: 'missingSuffix', data: { value: 'github' } }],
output: createCredentialCode('githubApi'),
},
{
name: 'name field with uppercase first char',
filename: credFilePath,
code: createCredentialCode('GithubApi'),
errors: [{ messageId: 'uppercaseFirstChar', data: { value: 'GithubApi' } }],
output: createCredentialCode('githubApi'),
},
{
name: 'name field with both uppercase first char and missing suffix',
filename: credFilePath,
code: createCredentialCode('Github'),
errors: [
{ messageId: 'uppercaseFirstChar', data: { value: 'Github' } },
{ messageId: 'missingSuffix', data: { value: 'Github' } },
],
output: createCredentialCode('githubApi'),
},
{
name: 'name field ending in Ap',
filename: credFilePath,
code: createCredentialCode('githubAp'),
errors: [{ messageId: 'missingSuffix', data: { value: 'githubAp' } }],
output: createCredentialCode('githubApi'),
},
{
name: 'name field ending in A',
filename: credFilePath,
code: createCredentialCode('githubA'),
errors: [{ messageId: 'missingSuffix', data: { value: 'githubA' } }],
output: createCredentialCode('githubApi'),
},
{
name: 'autofix escapes single quotes in the name value',
filename: credFilePath,
code: createCredentialCodeWithLiteral("'git\\'hub'"),
errors: [{ messageId: 'missingSuffix', data: { value: "git'hub" } }],
output: createCredentialCodeWithLiteral("'git\\'hubApi'"),
},
{
name: 'autofix preserves double quotes and escapes them in the name value',
filename: credFilePath,
code: createCredentialCodeWithLiteral('"Git\\"hub"'),
errors: [
{ messageId: 'uppercaseFirstChar', data: { value: 'Git"hub' } },
{ messageId: 'missingSuffix', data: { value: 'Git"hub' } },
],
output: createCredentialCodeWithLiteral('"git\\"hubApi"'),
},
],
});

View File

@ -1,102 +0,0 @@
import {
createRule,
findClassProperty,
getStringLiteralValue,
isCredentialTypeClass,
isFileType,
} from '../utils/index.js';
function lowercaseFirstChar(name: string): string {
return name.charAt(0).toLowerCase() + name.slice(1);
}
function addApiSuffix(name: string): string {
if (name.endsWith('Api')) return name;
if (name.endsWith('Ap')) return `${name}i`;
if (name.endsWith('A')) return `${name}pi`;
return `${name}Api`;
}
// Serialize a value as a string literal using the original quote character,
// escaping any characters that would otherwise break the literal so the
// autofix never emits invalid code (e.g. names containing quotes).
function toStringLiteral(value: string, quote: string): string {
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.split(quote)
.join(`\\${quote}`);
return `${quote}${escaped}${quote}`;
}
export const CredClassNameFieldConventionsRule = createRule({
name: 'cred-class-name-field-conventions',
meta: {
type: 'problem',
docs: {
description: 'Credential `name` field must end with `Api` and start with a lowercase letter',
},
messages: {
missingSuffix: "Credential `name` field '{{value}}' must end with 'Api'",
uppercaseFirstChar: "Credential `name` field '{{value}}' must start with a lowercase letter",
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.credentials.ts')) {
return {};
}
return {
ClassDeclaration(node) {
if (!isCredentialTypeClass(node)) {
return;
}
const nameProperty = findClassProperty(node, 'name');
if (!nameProperty?.value) {
return;
}
const nameValue = getStringLiteralValue(nameProperty.value);
if (nameValue === null) {
return;
}
const startsLowercase = !/^[A-Z]/.test(nameValue);
const endsWithApi = nameValue.endsWith('Api');
if (startsLowercase && endsWithApi) {
return;
}
// Compute the fully-corrected value once so each fix yields the
// same result in a single pass, regardless of which one applies.
const fixedValue = addApiSuffix(lowercaseFirstChar(nameValue));
const valueNode = nameProperty.value;
const quote = context.sourceCode.getText(valueNode).charAt(0);
const replacement = toStringLiteral(fixedValue, quote);
if (!startsLowercase) {
context.report({
node: valueNode,
messageId: 'uppercaseFirstChar',
data: { value: nameValue },
fix: (fixer) => fixer.replaceText(valueNode, replacement),
});
}
if (!endsWithApi) {
context.report({
node: valueNode,
messageId: 'missingSuffix',
data: { value: nameValue },
fix: (fixer) => fixer.replaceText(valueNode, replacement),
});
}
},
};
},
});

View File

@ -1,74 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { CredClassNameSuffixRule } from './cred-class-name-suffix.js';
const ruleTester = new RuleTester();
const credFilePath = '/tmp/TestCredential.credentials.ts';
const nonCredFilePath = '/tmp/SomeHelper.ts';
function createCredentialCode(className: string): string {
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class ${className} implements ICredentialType {
name = 'testApi';
displayName = 'Test API';
properties: INodeProperties[] = [];
}`;
}
function createRegularClass(className: string): string {
return `
export class ${className} {
name = 'test';
}`;
}
ruleTester.run('cred-class-name-suffix', CredClassNameSuffixRule, {
valid: [
{
name: 'credential class with Api suffix',
filename: credFilePath,
code: createCredentialCode('TestApi'),
},
{
name: 'credential class with OAuth2Api suffix',
filename: credFilePath,
code: createCredentialCode('TestOAuth2Api'),
},
{
name: 'class not implementing ICredentialType is ignored',
filename: credFilePath,
code: createRegularClass('SomeHelper'),
},
{
name: 'non-.credentials.ts file is ignored',
filename: nonCredFilePath,
code: createCredentialCode('TestCredential'),
},
],
invalid: [
{
name: 'credential class missing Api suffix',
filename: credFilePath,
code: createCredentialCode('TestCredential'),
errors: [{ messageId: 'missingSuffix', data: { name: 'TestCredential' } }],
output: createCredentialCode('TestCredentialApi'),
},
{
name: 'credential class name ending in Ap',
filename: credFilePath,
code: createCredentialCode('TestAp'),
errors: [{ messageId: 'missingSuffix', data: { name: 'TestAp' } }],
output: createCredentialCode('TestApi'),
},
{
name: 'credential class name ending in A',
filename: credFilePath,
code: createCredentialCode('TestA'),
errors: [{ messageId: 'missingSuffix', data: { name: 'TestA' } }],
output: createCredentialCode('TestApi'),
},
],
});

View File

@ -1,57 +0,0 @@
import { isCredentialTypeClass, isFileType, createRule } from '../utils/index.js';
function addApiSuffix(name: string): string {
if (name.endsWith('Ap')) return `${name}i`;
if (name.endsWith('A')) return `${name}pi`;
return `${name}Api`;
}
export const CredClassNameSuffixRule = createRule({
name: 'cred-class-name-suffix',
meta: {
type: 'problem',
docs: {
description: 'Credential class names must be suffixed with `Api`',
},
messages: {
missingSuffix: "Credential class name '{{name}}' must end with 'Api'",
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.credentials.ts')) {
return {};
}
return {
ClassDeclaration(node) {
if (!isCredentialTypeClass(node)) {
return;
}
const classNameNode = node.id;
if (!classNameNode) {
return;
}
const className = classNameNode.name;
if (className.endsWith('Api')) {
return;
}
const fixedName = addApiSuffix(className);
context.report({
node: classNameNode,
messageId: 'missingSuffix',
data: { name: className },
fix(fixer) {
return fixer.replaceText(classNameNode, fixedName);
},
});
},
};
},
});

View File

@ -1,72 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { CredFilenameAgainstConventionRule } from './cred-filename-against-convention.js';
const ruleTester = new RuleTester();
function createCredentialCode(className: string): string {
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class ${className} implements ICredentialType {
name = '${className.charAt(0).toLowerCase() + className.slice(1)}';
displayName = '${className}';
properties: INodeProperties[] = [];
}`;
}
function createRegularClass(): string {
return `
export class SomeHelper {
name = 'githubApi';
}`;
}
ruleTester.run('cred-filename-against-convention', CredFilenameAgainstConventionRule, {
valid: [
{
name: 'filename matches credential class name',
filename: '/tmp/GithubApi.credentials.ts',
code: createCredentialCode('GithubApi'),
},
{
name: 'multi-word class name matches filename',
filename: '/tmp/GoogleSheetsOAuth2Api.credentials.ts',
code: createCredentialCode('GoogleSheetsOAuth2Api'),
},
{
name: 'class not implementing ICredentialType is ignored',
filename: '/tmp/Github.credentials.ts',
code: createRegularClass(),
},
{
name: 'non-.credentials.ts file is ignored',
filename: '/tmp/GithubApi.ts',
code: createCredentialCode('Mismatch'),
},
],
invalid: [
{
name: 'filename does not match class name',
filename: '/tmp/GithubApi.credentials.ts',
code: createCredentialCode('GitlabApi'),
errors: [
{
messageId: 'renameFile',
data: { className: 'GitlabApi', expected: 'GitlabApi.credentials.ts' },
},
],
},
{
name: 'filename has wrong casing',
filename: '/tmp/githubApi.credentials.ts',
code: createCredentialCode('GithubApi'),
errors: [
{
messageId: 'renameFile',
data: { className: 'GithubApi', expected: 'GithubApi.credentials.ts' },
},
],
},
],
});

View File

@ -1,48 +0,0 @@
import * as path from 'node:path';
import { isCredentialTypeClass, isFileType, createRule } from '../utils/index.js';
export const CredFilenameAgainstConventionRule = createRule({
name: 'cred-filename-against-convention',
meta: {
type: 'problem',
docs: {
description: 'Credential filename must match the credential class name',
},
messages: {
renameFile:
'Credential filename must match the class name "{{className}}". Rename file to "{{expected}}".',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.credentials.ts')) {
return {};
}
return {
ClassDeclaration(node) {
if (!isCredentialTypeClass(node)) {
return;
}
const classNameNode = node.id;
if (!classNameNode) {
return;
}
const className = classNameNode.name;
const actualBaseName = path.basename(context.filename, '.credentials.ts');
if (actualBaseName !== className) {
context.report({
node: classNameNode,
messageId: 'renameFile',
data: { className, expected: `${className}.credentials.ts` },
});
}
},
};
},
});

View File

@ -2,10 +2,7 @@ import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
import { CredClassFieldIconMissingRule } from './cred-class-field-icon-missing.js';
import { CredClassNameFieldConventionsRule } from './cred-class-name-field-conventions.js';
import { CredClassNameSuffixRule } from './cred-class-name-suffix.js';
import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js';
import { CredFilenameAgainstConventionRule } from './cred-filename-against-convention.js';
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
import { CredentialPasswordFieldRule } from './credential-password-field.js';
import { CredentialTestRequiredRule } from './credential-test-required.js';
@ -13,12 +10,10 @@ import { IconPreferThemedVariantsRule } from './icon-prefer-themed-variants.js';
import { IconValidationRule } from './icon-validation.js';
import { MissingPairedItemRule } from './missing-paired-item.js';
import { N8nObjectValidationRule } from './n8n-object-validation.js';
import { NoAsteriskInOptionNamesRule } from './no-asterisk-in-option-names.js';
import { NoBuilderHintLeakageRule } from './no-builder-hint-leakage.js';
import { NoCredentialReuseRule } from './no-credential-reuse.js';
import { NoDangerousFunctionsRule } from './no-dangerous-functions.js';
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
import { NoDuplicateParamOptionsRule } from './no-duplicate-param-options.js';
import { NoEmojiInOptionsRule } from './no-emoji-in-options.js';
import { NoForbiddenLifecycleScriptsRule } from './no-forbidden-lifecycle-scripts.js';
import { NoHttpRequestWithManualAuthRule } from './no-http-request-with-manual-auth.js';
@ -28,20 +23,12 @@ import { NoRestrictedImportsRule } from './no-restricted-imports.js';
import { NoRuntimeDependenciesRule } from './no-runtime-dependencies.js';
import { NoTemplatePlaceholdersRule } from './no-template-placeholders.js';
import { NodeClassDescriptionIconMissingRule } from './node-class-description-icon-missing.js';
import { NodeClassDescriptionNameCamelCaseRule } from './node-class-description-name-camelcase.js';
import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js';
import { NodeFilenameAgainstConventionRule } from './node-filename-against-convention.js';
import { NodeOperationErrorItemIndexRule } from './node-operation-error-itemindex.js';
import { NodeRegistrationCompleteRule } from './node-registration-complete.js';
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
import { OptionsSortedAlphabeticallyRule } from './options-sorted-alphabetically.js';
import { PackageNameConventionRule } from './package-name-convention.js';
import { RequireCommunityNodeKeywordRule } from './require-community-node-keyword.js';
import { RequireContinueOnFailRule } from './require-continue-on-fail.js';
import { RequireMitLicenseRule } from './require-mit-license.js';
import { RequireNodeApiErrorRule } from './require-node-api-error.js';
import { RequireNodeDescriptionFieldsRule } from './require-node-description-fields.js';
import { RequireParamDefaultRule } from './require-param-default.js';
import { RequireVersionRule } from './require-version.js';
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
import { TriggerNodeConventionsRule } from './trigger-node-conventions.js';
@ -58,10 +45,7 @@ export const rules = {
'credential-password-field': CredentialPasswordFieldRule,
'no-deprecated-workflow-functions': NoDeprecatedWorkflowFunctionsRule,
'no-emoji-in-options': NoEmojiInOptionsRule,
'no-asterisk-in-option-names': NoAsteriskInOptionNamesRule,
'no-duplicate-param-options': NoDuplicateParamOptionsRule,
'node-usable-as-tool': NodeUsableAsToolRule,
'options-sorted-alphabetically': OptionsSortedAlphabeticallyRule,
'package-name-convention': PackageNameConventionRule,
'credential-test-required': CredentialTestRequiredRule,
'no-credential-reuse': NoCredentialReuseRule,
@ -77,25 +61,15 @@ export const rules = {
'trigger-node-conventions': TriggerNodeConventionsRule,
'credential-documentation-url': CredentialDocumentationUrlRule,
'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule,
'node-class-description-name-camelcase': NodeClassDescriptionNameCamelCaseRule,
'cred-class-field-icon-missing': CredClassFieldIconMissingRule,
'cred-class-name-field-conventions': CredClassNameFieldConventionsRule,
'cred-class-name-suffix': CredClassNameSuffixRule,
'cred-class-oauth2-naming': CredClassOAuth2NamingRule,
'cred-filename-against-convention': CredFilenameAgainstConventionRule,
'node-connection-type-literal': NodeConnectionTypeLiteralRule,
'node-filename-against-convention': NodeFilenameAgainstConventionRule,
'node-operation-error-itemindex': NodeOperationErrorItemIndexRule,
'node-registration-complete': NodeRegistrationCompleteRule,
'missing-paired-item': MissingPairedItemRule,
'no-builder-hint-leakage': NoBuilderHintLeakageRule,
'n8n-object-validation': N8nObjectValidationRule,
'require-community-node-keyword': RequireCommunityNodeKeywordRule,
'require-continue-on-fail': RequireContinueOnFailRule,
'require-mit-license': RequireMitLicenseRule,
'require-node-api-error': RequireNodeApiErrorRule,
'require-node-description-fields': RequireNodeDescriptionFieldsRule,
'require-param-default': RequireParamDefaultRule,
'require-version': RequireVersionRule,
'valid-author': ValidAuthorRule,
'valid-credential-references': ValidCredentialReferencesRule,

View File

@ -1,185 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoAsteriskInOptionNamesRule } from './no-asterisk-in-option-names.js';
const ruleTester = new RuleTester();
function createNodeCode(body: string): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
${body}
};
}
`;
}
ruleTester.run('no-asterisk-in-option-names', NoAsteriskInOptionNamesRule, {
valid: [
{
name: 'class that does not implement INodeType',
filename: '/tmp/TestNode.node.ts',
code: `
export class NotANode {
description = {
properties: [
{ options: [{ name: '* All', value: 'all' }] },
],
};
}
`,
},
{
name: 'non .node.ts file is ignored',
filename: '/tmp/helper.ts',
code: `
export class TestNode {
description = {
properties: [
{ options: [{ name: '* All', value: 'all' }] },
],
};
}
`,
},
{
name: 'option names without asterisk',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Create', value: 'create' },
{ name: '[All]', value: 'all' },
],
default: 'create',
},
],
`),
},
{
name: 'asterisk in displayName outside options is ignored',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
displayName: 'Test * Node',
name: 'testNode',
properties: [
{ displayName: 'Field *', name: 'field', type: 'string', default: '' },
],
`),
},
],
invalid: [
{
name: 'asterisk in option name within options array',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: '* All', value: 'all' },
{ name: 'User', value: 'user' },
],
default: 'all',
},
],
`),
errors: [
{
messageId: 'asteriskInOptionName',
data: { name: '* All' },
suggestions: [
{
messageId: 'replaceAsterisk',
output: createNodeCode(`
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: '[All] All', value: 'all' },
{ name: 'User', value: 'user' },
],
default: 'all',
},
],
`),
},
],
},
],
},
{
name: 'asterisk in nested collection options',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
default: {},
options: [
{
displayName: 'Status',
name: 'status',
type: 'options',
options: [{ name: '*', value: 'any' }],
default: 'any',
},
],
},
],
`),
errors: [
{
messageId: 'asteriskInOptionName',
data: { name: '*' },
suggestions: [
{
messageId: 'replaceAsterisk',
output: createNodeCode(`
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
default: {},
options: [
{
displayName: 'Status',
name: 'status',
type: 'options',
options: [{ name: '[All]', value: 'any' }],
default: 'any',
},
],
},
],
`),
},
],
},
],
},
],
});

View File

@ -1,111 +0,0 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import {
isNodeTypeClass,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
isFileType,
createRule,
} from '../utils/index.js';
export const NoAsteriskInOptionNamesRule = createRule({
name: 'no-asterisk-in-option-names',
meta: {
type: 'suggestion',
docs: {
description: 'Disallow asterisk characters in node option name values',
},
hasSuggestions: true,
messages: {
asteriskInOptionName:
'Option name "{{ name }}" contains "*", which renders ambiguously in the n8n UI. Use bracketed notation like "[All]" instead.',
replaceAsterisk: 'Replace "*" with "[All]".',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
// Check every object that is a direct element of an `options` array.
const checkOptionsArray = (optionsArray: TSESTree.ArrayExpression): void => {
for (const option of optionsArray.elements) {
if (!option || option.type !== AST_NODE_TYPES.ObjectExpression) {
continue;
}
const valueNode = findObjectProperty(option, 'name')?.value;
if (!valueNode) {
continue;
}
const name = getStringLiteralValue(valueNode);
if (!name?.includes('*')) {
continue;
}
context.report({
node: valueNode,
messageId: 'asteriskInOptionName',
data: { name },
suggest: [
{
messageId: 'replaceAsterisk',
fix(fixer) {
const quote = context.sourceCode.getText(valueNode).at(0) ?? "'";
return fixer.replaceText(
valueNode,
`${quote}${name.replaceAll('*', '[All]')}${quote}`,
);
},
},
],
});
}
};
const traverse = (node: TSESTree.Node): void => {
if (node.type === AST_NODE_TYPES.ObjectExpression) {
const optionsProperty = findObjectProperty(node, 'options');
if (optionsProperty?.value.type === AST_NODE_TYPES.ArrayExpression) {
checkOptionsArray(optionsProperty.value);
}
}
for (const key in node) {
if (key === 'parent') {
continue;
}
const child = node[key as keyof TSESTree.Node] as unknown;
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item === 'object' && 'type' in item) {
traverse(item as TSESTree.Node);
}
}
} else if (child && typeof child === 'object' && 'type' in child) {
traverse(child as TSESTree.Node);
}
}
};
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
const descriptionProperty = findClassProperty(node, 'description');
if (!descriptionProperty?.value) {
return;
}
traverse(descriptionProperty.value);
},
};
},
});

View File

@ -1,125 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoDuplicateParamOptionsRule } from './no-duplicate-param-options.js';
const ruleTester = new RuleTester();
function nodeCode(options: string, type = 'options'): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: '${type}',
options: [${options}],
default: '',
},
],
};
}
`;
}
ruleTester.run('no-duplicate-param-options', NoDuplicateParamOptionsRule, {
valid: [
{
name: 'non-INodeType class is skipped',
filename: '/tmp/TestNode.node.ts',
code: 'export class Foo {}',
},
{
name: 'unique names and values',
filename: '/tmp/TestNode.node.ts',
code: nodeCode(`
{ name: 'Create', value: 'create' },
{ name: 'Delete', value: 'delete' },
`),
},
{
name: 'unique numeric values',
filename: '/tmp/TestNode.node.ts',
code: nodeCode(`
{ name: 'One', value: 1 },
{ name: 'Two', value: 2 },
`),
},
{
name: 'multiOptions with unique options',
filename: '/tmp/TestNode.node.ts',
code: nodeCode(
`
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
`,
'multiOptions',
),
},
{
name: 'non-node.ts file is skipped',
filename: '/tmp/TestNode.ts',
code: nodeCode(`
{ name: 'Dup', value: 'a' },
{ name: 'Dup', value: 'b' },
`),
},
],
invalid: [
{
name: 'duplicate name',
filename: '/tmp/TestNode.node.ts',
code: nodeCode(`
{ name: 'Create', value: 'create' },
{ name: 'Create', value: 'create2' },
`),
errors: [{ messageId: 'duplicateName', data: { value: 'Create', displayName: 'Resource' } }],
},
{
name: 'duplicate value',
filename: '/tmp/TestNode.node.ts',
code: nodeCode(`
{ name: 'Create', value: 'create' },
{ name: 'Make', value: 'create' },
`),
errors: [{ messageId: 'duplicateValue', data: { value: 'create', displayName: 'Resource' } }],
},
{
name: 'duplicate numeric value',
filename: '/tmp/TestNode.node.ts',
code: nodeCode(`
{ name: 'One', value: 1 },
{ name: 'Uno', value: 1 },
`),
errors: [{ messageId: 'duplicateValue', data: { value: '1', displayName: 'Resource' } }],
},
{
name: 'duplicate name and value reported separately',
filename: '/tmp/TestNode.node.ts',
code: nodeCode(`
{ name: 'Create', value: 'create' },
{ name: 'Create', value: 'create' },
`),
errors: [
{ messageId: 'duplicateName', data: { value: 'Create', displayName: 'Resource' } },
{ messageId: 'duplicateValue', data: { value: 'create', displayName: 'Resource' } },
],
},
{
name: 'duplicate in multiOptions',
filename: '/tmp/TestNode.node.ts',
code: nodeCode(
`
{ name: 'A', value: 'a' },
{ name: 'A', value: 'b' },
`,
'multiOptions',
),
errors: [{ messageId: 'duplicateName', data: { value: 'A', displayName: 'Resource' } }],
},
],
});

View File

@ -1,125 +0,0 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import {
isNodeTypeClass,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
getLiteralValue,
isFileType,
createRule,
} from '../utils/index.js';
export const NoDuplicateParamOptionsRule = createRule({
name: 'no-duplicate-param-options',
meta: {
type: 'problem',
docs: {
description: 'Disallow duplicate option names or values within a single node parameter',
},
messages: {
duplicateName: 'Duplicate option name "{{ value }}" in parameter "{{ displayName }}".',
duplicateValue: 'Duplicate option value "{{ value }}" in parameter "{{ displayName }}".',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
const checkProperty = (property: TSESTree.ObjectExpression): void => {
const typeProperty = findObjectProperty(property, 'type');
const type = typeProperty ? getStringLiteralValue(typeProperty.value) : null;
if (type !== 'options' && type !== 'multiOptions') {
return;
}
const optionsProperty = findObjectProperty(property, 'options');
if (
!optionsProperty?.value ||
optionsProperty.value.type !== AST_NODE_TYPES.ArrayExpression
) {
return;
}
const displayNameProperty = findObjectProperty(property, 'displayName');
const displayName =
(displayNameProperty ? getStringLiteralValue(displayNameProperty.value) : null) ??
'unknown';
const seenNames = new Set<string>();
const seenValues = new Set<string | number | boolean>();
for (const option of optionsProperty.value.elements) {
if (!option || option.type !== AST_NODE_TYPES.ObjectExpression) {
continue;
}
const nameProperty = findObjectProperty(option, 'name');
const name = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
if (name !== null) {
if (seenNames.has(name)) {
context.report({
node: nameProperty!.value,
messageId: 'duplicateName',
data: { value: name, displayName },
});
}
seenNames.add(name);
}
const valueProperty = findObjectProperty(option, 'value');
const value = valueProperty ? getLiteralValue(valueProperty.value) : null;
if (value !== null) {
if (seenValues.has(value)) {
context.report({
node: valueProperty!.value,
messageId: 'duplicateValue',
data: { value: String(value), displayName },
});
}
seenValues.add(value);
}
}
};
const analyzeNodeDescription = (descriptionValue: TSESTree.Expression | null): void => {
if (!descriptionValue || descriptionValue.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
const propertiesProperty = findObjectProperty(descriptionValue, 'properties');
if (
!propertiesProperty?.value ||
propertiesProperty.value.type !== AST_NODE_TYPES.ArrayExpression
) {
return;
}
for (const property of propertiesProperty.value.elements) {
if (!property || property.type !== AST_NODE_TYPES.ObjectExpression) {
continue;
}
checkProperty(property);
}
};
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
const descriptionProperty = findClassProperty(node, 'description');
if (!descriptionProperty) {
return;
}
analyzeNodeDescription(descriptionProperty.value);
},
};
},
});

View File

@ -1,121 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NodeClassDescriptionNameCamelCaseRule } from './node-class-description-name-camelcase.js';
const ruleTester = new RuleTester();
function createNodeCode(name: string): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: '${name}',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: [],
outputs: [],
properties: [],
};
}`;
}
function createNodeCodeWithoutName(): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: [],
outputs: [],
properties: [],
};
}`;
}
function createRegularClass(): string {
return `
export class SomeHelper {
name = 'My Helper';
}`;
}
ruleTester.run('node-class-description-name-camelcase', NodeClassDescriptionNameCamelCaseRule, {
valid: [
{
name: 'single lowercase word',
filename: '/tmp/Github.node.ts',
code: createNodeCode('github'),
},
{
name: 'camelCase multi-word name',
filename: '/tmp/GoogleSheets.node.ts',
code: createNodeCode('googleSheets'),
},
{
name: 'camelCase name with digits',
filename: '/tmp/Oauth2.node.ts',
code: createNodeCode('myNode2'),
},
{
name: 'class not implementing INodeType is ignored',
filename: '/tmp/Github.node.ts',
code: createRegularClass(),
},
{
name: 'non-.node.ts file is ignored',
filename: '/tmp/Github.ts',
code: createNodeCode('My Node'),
},
{
name: 'missing description.name is ignored',
filename: '/tmp/Github.node.ts',
code: createNodeCodeWithoutName(),
},
],
invalid: [
{
name: 'PascalCase first letter is lowercased',
filename: '/tmp/Github.node.ts',
code: createNodeCode('MyNode'),
errors: [{ messageId: 'notCamelCase', data: { value: 'MyNode' } }],
output: createNodeCode('myNode'),
},
{
name: 'spaces are removed and camelCased',
filename: '/tmp/Github.node.ts',
code: createNodeCode('My Node'),
errors: [{ messageId: 'notCamelCase', data: { value: 'My Node' } }],
output: createNodeCode('myNode'),
},
{
name: 'hyphen separators are removed and camelCased',
filename: '/tmp/Github.node.ts',
code: createNodeCode('my-node'),
errors: [{ messageId: 'notCamelCase', data: { value: 'my-node' } }],
output: createNodeCode('myNode'),
},
{
name: 'underscore separators are removed and camelCased',
filename: '/tmp/Github.node.ts',
code: createNodeCode('my_cool_node'),
errors: [{ messageId: 'notCamelCase', data: { value: 'my_cool_node' } }],
output: createNodeCode('myCoolNode'),
},
{
name: 'names starting with a digit are reported without an autofix',
filename: '/tmp/Github.node.ts',
code: createNodeCode('123node'),
errors: [{ messageId: 'notCamelCase', data: { value: '123node' } }],
output: null,
},
],
});

View File

@ -1,114 +0,0 @@
import { TSESTree } from '@typescript-eslint/utils';
import {
isNodeTypeClass,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
isFileType,
createRule,
} from '../utils/index.js';
// `description.name` must be camelCase: a lowercase letter followed by
// letters/digits, with no separators.
const CAMEL_CASE_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
/**
* Converts an arbitrary node name to camelCase by splitting on any
* non-alphanumeric separators, upper-casing the first character of each
* subsequent segment, and lower-casing the very first character.
* Examples: `My Node` -> `myNode`, `my-node` -> `myNode`, `GitHub` -> `gitHub`.
*/
function toCamelCase(value: string): string {
const segments = value.split(/[^a-zA-Z0-9]+/).filter(Boolean);
if (segments.length === 0) {
return value;
}
const joined = segments
.map((segment, index) =>
index === 0 ? segment : segment.charAt(0).toUpperCase() + segment.slice(1),
)
.join('');
return joined.charAt(0).toLowerCase() + joined.slice(1);
}
// Serialize a value as a string literal using the original quote character,
// escaping any characters that would otherwise break the literal so the
// autofix never emits invalid code.
function toStringLiteral(value: string, quote: string): string {
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.split(quote)
.join(`\\${quote}`);
return `${quote}${escaped}${quote}`;
}
export const NodeClassDescriptionNameCamelCaseRule = createRule({
name: 'node-class-description-name-camelcase',
meta: {
type: 'problem',
docs: {
description: 'Node class `description.name` must be camelCase',
},
messages: {
notCamelCase: "Node `description.name` '{{value}}' must be camelCase",
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
const descriptionProperty = findClassProperty(node, 'description');
if (descriptionProperty?.value?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
return;
}
const nameProperty = findObjectProperty(descriptionProperty.value, 'name');
if (!nameProperty) {
return;
}
const nameValue = getStringLiteralValue(nameProperty.value);
if (nameValue === null) {
return;
}
if (CAMEL_CASE_PATTERN.test(nameValue)) {
return;
}
const valueNode = nameProperty.value;
const fixedValue = toCamelCase(nameValue);
// Only offer an autofix when it actually yields a valid camelCase
// value (e.g. names starting with a digit cannot be repaired).
const canFix = fixedValue !== nameValue && CAMEL_CASE_PATTERN.test(fixedValue);
context.report({
node: valueNode,
messageId: 'notCamelCase',
data: { value: nameValue },
fix: canFix
? (fixer) => {
const quote = context.sourceCode.getText(valueNode).charAt(0);
return fixer.replaceText(valueNode, toStringLiteral(fixedValue, quote));
}
: undefined,
});
},
};
},
});

View File

@ -1,115 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NodeFilenameAgainstConventionRule } from './node-filename-against-convention.js';
const ruleTester = new RuleTester();
function createNodeCode(name: string): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: '${name}',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: [],
outputs: [],
properties: [],
};
}`;
}
function createNodeCodeWithoutName(): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: [],
outputs: [],
properties: [],
};
}`;
}
function createRegularClass(): string {
return `
export class SomeHelper {
name = 'github';
}`;
}
ruleTester.run('node-filename-against-convention', NodeFilenameAgainstConventionRule, {
valid: [
{
name: 'filename matches PascalCased description.name',
filename: '/tmp/Github.node.ts',
code: createNodeCode('github'),
},
{
name: 'filename with version suffix is accepted',
filename: '/tmp/GithubV2.node.ts',
code: createNodeCode('github'),
},
{
name: 'filename with multi-digit version suffix is accepted',
filename: '/tmp/GithubV10.node.ts',
code: createNodeCode('github'),
},
{
name: 'multi-word camelCase name maps to PascalCase filename',
filename: '/tmp/GoogleSheets.node.ts',
code: createNodeCode('googleSheets'),
},
{
name: 'class not implementing INodeType is ignored',
filename: '/tmp/Github.node.ts',
code: createRegularClass(),
},
{
name: 'non-.node.ts file is ignored',
filename: '/tmp/Github.ts',
code: createNodeCode('mismatch'),
},
{
name: 'missing description.name is ignored',
filename: '/tmp/Github.node.ts',
code: createNodeCodeWithoutName(),
},
],
invalid: [
{
name: 'filename does not match description.name',
filename: '/tmp/Github.node.ts',
code: createNodeCode('gitlab'),
errors: [{ messageId: 'renameFile', data: { expected: 'Gitlab.node.ts' } }],
},
{
name: 'filename is lowercased',
filename: '/tmp/github.node.ts',
code: createNodeCode('github'),
errors: [{ messageId: 'renameFile', data: { expected: 'Github.node.ts' } }],
},
{
name: 'filename has wrong internal casing',
filename: '/tmp/GitHub.node.ts',
code: createNodeCode('github'),
errors: [{ messageId: 'renameFile', data: { expected: 'Github.node.ts' } }],
},
{
name: 'version suffix does not excuse a mismatched base name',
filename: '/tmp/GitlabV2.node.ts',
code: createNodeCode('github'),
errors: [{ messageId: 'renameFile', data: { expected: 'Github.node.ts' } }],
},
],
});

View File

@ -1,76 +0,0 @@
import { TSESTree } from '@typescript-eslint/utils';
import * as path from 'node:path';
import {
isNodeTypeClass,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
isFileType,
createRule,
} from '../utils/index.js';
/**
* Converts a `description.name` to the expected node file basename by
* upper-casing the first character. The source of truth is `description.name`,
* not the class name. Example: `github` -> `Github`.
*/
function toExpectedBaseName(name: string): string {
return name.charAt(0).toUpperCase() + name.slice(1);
}
export const NodeFilenameAgainstConventionRule = createRule({
name: 'node-filename-against-convention',
meta: {
type: 'problem',
docs: {
description: 'Node filename must match the node `description.name`',
},
messages: {
renameFile: 'Node filename must match `description.name`. Rename file to "{{expected}}".',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
const descriptionProperty = findClassProperty(node, 'description');
if (descriptionProperty?.value?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
return;
}
const nameProperty = findObjectProperty(descriptionProperty.value, 'name');
if (!nameProperty) {
return;
}
const name = getStringLiteralValue(nameProperty.value);
if (!name) {
return;
}
const expectedBaseName = toExpectedBaseName(name);
// Strip the `.node.ts` extension and any trailing `V<digits>` version
// suffix so versioned files (e.g. `GithubV2.node.ts`) are accepted.
const actualBaseName = path.basename(context.filename, '.node.ts').replace(/V\d+$/, '');
if (actualBaseName !== expectedBaseName) {
context.report({
node: nameProperty.value,
messageId: 'renameFile',
data: { expected: `${expectedBaseName}.node.ts` },
});
}
},
};
},
});

View File

@ -1,280 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NodeOperationErrorItemIndexRule } from './node-operation-error-itemindex.js';
const ruleTester = new RuleTester();
const NODE_FILENAME = 'TestNode.node.ts';
function createNodeWithExecute(executeBody: string): { filename: string; code: string } {
return {
filename: NODE_FILENAME,
code: `
import type { INodeType, INodeTypeDescription, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError, NodeApiError } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
${executeBody}
}
}`,
};
}
ruleTester.run('node-operation-error-itemindex', NodeOperationErrorItemIndexRule, {
valid: [
{
name: 'non-node class is ignored',
filename: NODE_FILENAME,
code: `
export class RegularClass {
async execute() {
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeOperationError(this.getNode(), 'error');
}
}
}`,
},
{
name: 'NodeOperationError outside any loop is allowed',
...createNodeWithExecute(`
throw new NodeOperationError(this.getNode(), 'some error');
`),
},
{
name: 'NodeOperationError in a non-item loop is allowed',
...createNodeWithExecute(`
const settings = ['a', 'b', 'c'];
for (let i = 0; i < settings.length; i++) {
throw new NodeOperationError(this.getNode(), 'error');
}
`),
},
{
name: 'NodeOperationError with itemIndex in C-style for loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeOperationError(this.getNode(), 'error', { itemIndex: i });
}
`),
},
{
name: 'NodeOperationError with itemIndex shorthand in C-style for loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
throw new NodeOperationError(this.getNode(), 'error', { itemIndex });
}
`),
},
{
name: 'NodeApiError with itemIndex in C-style for loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeApiError(this.getNode(), error, { itemIndex: i });
}
`),
},
{
name: 'NodeOperationError with itemIndex in for...of loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (const [i, item] of items.entries()) {
throw new NodeOperationError(this.getNode(), 'error', { itemIndex: i });
}
`),
},
{
name: 'NodeOperationError with itemIndex in for...of directly over getInputData()',
...createNodeWithExecute(`
let i = 0;
for (const item of this.getInputData()) {
throw new NodeOperationError(this.getNode(), 'error', { itemIndex: i++ });
}
`),
},
{
name: 'NodeOperationError with variable as 3rd arg (cannot statically verify — skip)',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
const opts = { itemIndex: i };
throw new NodeOperationError(this.getNode(), 'error', opts);
}
`),
},
{
name: 'NodeOperationError with spread plus explicit itemIndex in options',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeOperationError(this.getNode(), 'error', { ...opts, itemIndex: i });
}
`),
},
{
name: 'NodeOperationError outside execute() method is not flagged',
filename: NODE_FILENAME,
code: `
import type { INodeType, INodeTypeDescription, IWebhookFunctions, IWebhookResponseData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['trigger'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: [],
outputs: ['main'],
webhooks: [{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' }],
properties: [],
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeOperationError(this.getNode(), 'webhook error');
}
return { workflowData: [[]] };
}
}`,
},
{
name: 'NodeOperationError in nested non-item for loop inside item loop is allowed',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
const options = ['a', 'b'];
for (let j = 0; j < options.length; j++) {
throw new NodeOperationError(this.getNode(), 'error', { itemIndex: i });
}
}
`),
},
],
invalid: [
{
name: 'NodeOperationError without any options in C-style for loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeOperationError(this.getNode(), 'error');
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } }],
},
{
name: 'NodeOperationError with empty options object in C-style for loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeOperationError(this.getNode(), 'error', {});
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } }],
},
{
name: 'NodeOperationError with options but missing itemIndex in C-style for loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeOperationError(this.getNode(), 'error', { description: 'something' });
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } }],
},
{
name: 'NodeApiError without itemIndex in C-style for loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeApiError(this.getNode(), error);
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeApiError' } }],
},
{
name: 'NodeOperationError without itemIndex in for...of over items variable',
...createNodeWithExecute(`
const items = this.getInputData();
for (const item of items) {
throw new NodeOperationError(this.getNode(), 'error');
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } }],
},
{
name: 'NodeOperationError without itemIndex in for...of directly over getInputData()',
...createNodeWithExecute(`
for (const item of this.getInputData()) {
throw new NodeOperationError(this.getNode(), 'error');
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } }],
},
{
name: 'multiple errors in the same item loop',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
if (someCondition) {
throw new NodeOperationError(this.getNode(), 'error A');
}
throw new NodeApiError(this.getNode(), error);
}
`),
errors: [
{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } },
{ messageId: 'missingItemIndex', data: { errorClass: 'NodeApiError' } },
],
},
{
name: 'NodeOperationError without itemIndex when loop variable is named itemIndex',
...createNodeWithExecute(`
const items = this.getInputData();
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
throw new NodeOperationError(this.getNode(), 'error', { description: 'oops' });
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } }],
},
{
name: 'NodeOperationError with spread-only options (spread does not guarantee itemIndex)',
...createNodeWithExecute(`
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
throw new NodeOperationError(this.getNode(), 'error', { ...opts });
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } }],
},
{
name: 'NodeOperationError without itemIndex with non-standard items variable name',
...createNodeWithExecute(`
const inputItems = this.getInputData();
for (let i = 0; i < inputItems.length; i++) {
throw new NodeOperationError(this.getNode(), 'error');
}
`),
errors: [{ messageId: 'missingItemIndex', data: { errorClass: 'NodeOperationError' } }],
},
],
});

View File

@ -1,223 +0,0 @@
/**
* Flags `new NodeOperationError(...)` or `new NodeApiError(...)` inside item
* loops in `execute()` methods that omit `{ itemIndex }` from the options
* argument. Without it, n8n cannot associate the error with the specific item
* that caused it, breaking per-item error reporting and `continueOnFail`.
*
* "Item loop" means a `for` or `for...of` that iterates over the result of
* `this.getInputData()` (or a variable initialised from it). Errors outside
* such loops — e.g. in webhook handlers or trigger setup — are not flagged.
*/
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
import { createRule, findObjectProperty, isFileType, isNodeTypeClass } from '../utils/index.js';
const ITEM_ERROR_CLASSES = new Set(['NodeOperationError', 'NodeApiError']);
/** Returns true when `node` is a bare `this.getInputData(...)` call. */
function isGetInputDataCall(node: TSESTree.CallExpression): boolean {
return (
node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.object.type === AST_NODE_TYPES.ThisExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
node.callee.property.name === 'getInputData'
);
}
/** Returns true when `node` is `<varName>.length` for any name in `varNames`. */
function isLengthAccessOnVariable(node: TSESTree.Node, varNames: Set<string>): boolean {
return (
node.type === AST_NODE_TYPES.MemberExpression &&
!node.computed &&
node.property.type === AST_NODE_TYPES.Identifier &&
node.property.name === 'length' &&
node.object.type === AST_NODE_TYPES.Identifier &&
varNames.has(node.object.name)
);
}
/**
* Returns true when the `for` test condition references `<itemVar>.length`,
* indicating that the loop iterates over an items array.
*/
function isItemForLoop(node: TSESTree.ForStatement, itemVarNames: Set<string>): boolean {
if (!node.test || node.test.type !== AST_NODE_TYPES.BinaryExpression) return false;
const { left, right } = node.test;
return (
isLengthAccessOnVariable(left, itemVarNames) || isLengthAccessOnVariable(right, itemVarNames)
);
}
/**
* Returns true when the `for...of` iterable is an items variable or a direct
* `this.getInputData()` call.
*/
function isItemForOfLoop(node: TSESTree.ForOfStatement, itemVarNames: Set<string>): boolean {
const { right } = node;
if (right.type === AST_NODE_TYPES.Identifier && itemVarNames.has(right.name)) {
return true;
}
return right.type === AST_NODE_TYPES.CallExpression && isGetInputDataCall(right);
}
/**
* Returns true when the `NodeOperationError` / `NodeApiError` constructor call
* already has an `{ itemIndex }` property in its options argument, or when the
* options argument cannot be statically inspected (variable / spread) — in
* which case we give the benefit of the doubt.
*/
function hasItemIndexOption(node: TSESTree.NewExpression): boolean {
const { arguments: args } = node;
if (args.length < 3) return false;
const optionsArg = args[2];
// Non-object-literal (bare variable reference) — can't statically check, assume OK.
if (!optionsArg || optionsArg.type !== AST_NODE_TYPES.ObjectExpression) {
return true;
}
// itemIndex must be an explicit own property of the options object.
// Spread elements (e.g. { ...opts }) are not sufficient — they may not
// include itemIndex and would silently bypass this requirement.
return findObjectProperty(optionsArg, 'itemIndex') !== null;
}
export const NodeOperationErrorItemIndexRule = createRule({
name: 'node-operation-error-itemindex',
meta: {
type: 'problem',
docs: {
description:
'Require { itemIndex } in NodeOperationError / NodeApiError options inside item loops',
},
messages: {
missingItemIndex:
'`new {{ errorClass }}(...)` inside an item loop must include `{ itemIndex }` as the ' +
'third argument so n8n can associate the error with the failing item.',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
let inNodeTypeClass = false;
let inExecuteMethod = false;
/** Names of variables initialised from `this.getInputData()` in the current execute() scope. */
const itemVariableNames = new Set<string>();
/** AST nodes for loops that are confirmed item loops. */
const itemLoopNodes = new Set<TSESTree.ForStatement | TSESTree.ForOfStatement>();
/** Number of currently open item loops (supports nested loops). */
let itemLoopDepth = 0;
function resetExecuteState() {
inExecuteMethod = false;
itemVariableNames.clear();
itemLoopNodes.clear();
itemLoopDepth = 0;
}
return {
ClassDeclaration(node) {
if (isNodeTypeClass(node)) {
inNodeTypeClass = true;
}
},
'ClassDeclaration:exit'() {
inNodeTypeClass = false;
resetExecuteState();
},
MethodDefinition(node: TSESTree.MethodDefinition) {
if (
inNodeTypeClass &&
node.key.type === AST_NODE_TYPES.Identifier &&
node.key.name === 'execute'
) {
inExecuteMethod = true;
}
},
'MethodDefinition:exit'(node: TSESTree.MethodDefinition) {
if (
inExecuteMethod &&
node.key.type === AST_NODE_TYPES.Identifier &&
node.key.name === 'execute'
) {
resetExecuteState();
}
},
VariableDeclarator(node: TSESTree.VariableDeclarator) {
if (!inExecuteMethod) return;
if (!node.init) return;
if (node.id.type !== AST_NODE_TYPES.Identifier) return;
if (node.init.type === AST_NODE_TYPES.CallExpression && isGetInputDataCall(node.init)) {
itemVariableNames.add(node.id.name);
}
},
ForStatement(node: TSESTree.ForStatement) {
if (!inExecuteMethod) return;
if (isItemForLoop(node, itemVariableNames)) {
itemLoopNodes.add(node);
itemLoopDepth++;
}
},
'ForStatement:exit'(node: TSESTree.ForStatement) {
if (itemLoopNodes.has(node)) {
itemLoopNodes.delete(node);
itemLoopDepth--;
}
},
ForOfStatement(node: TSESTree.ForOfStatement) {
if (!inExecuteMethod) return;
if (isItemForOfLoop(node, itemVariableNames)) {
itemLoopNodes.add(node);
itemLoopDepth++;
}
},
'ForOfStatement:exit'(node: TSESTree.ForOfStatement) {
if (itemLoopNodes.has(node)) {
itemLoopNodes.delete(node);
itemLoopDepth--;
}
},
NewExpression(node: TSESTree.NewExpression) {
if (itemLoopDepth === 0) return;
if (
node.callee.type !== AST_NODE_TYPES.Identifier ||
!ITEM_ERROR_CLASSES.has(node.callee.name)
) {
return;
}
if (!hasItemIndexOption(node)) {
context.report({
node,
messageId: 'missingItemIndex',
data: { errorClass: node.callee.name },
});
}
},
};
},
});

View File

@ -1,468 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { OptionsSortedAlphabeticallyRule } from './options-sorted-alphabetically.js';
const ruleTester = new RuleTester();
ruleTester.run('options-sorted-alphabetically', OptionsSortedAlphabeticallyRule, {
valid: [
{
name: 'resource options already sorted alphabetically',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'Contact', value: 'contact' },
{ name: 'Project', value: 'project' },
{ name: 'User', value: 'user' },
],
default: 'contact',
},
],
};
}
`,
},
{
name: 'operation options already sorted alphabetically',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Create', value: 'create' },
{ name: 'Delete', value: 'delete' },
{ name: 'Get', value: 'get' },
{ name: 'Update', value: 'update' },
],
default: 'get',
},
],
};
}
`,
},
{
name: 'non-ASCII names sorted correctly via localeCompare',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'Árbol', value: 'arbol' },
{ name: 'Empresa', value: 'empresa' },
{ name: 'Usuario', value: 'usuario' },
],
default: 'arbol',
},
],
};
}
`,
},
{
name: 'single option is trivially sorted',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
],
default: 'user',
},
],
};
}
`,
},
{
name: 'non-options type parameter is ignored',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
},
],
};
}
`,
},
{
name: 'non-node class is ignored',
filename: '/tmp/TestNode.node.ts',
code: `
export class NotANode {
description = {
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
{ name: 'Contact', value: 'contact' },
],
default: 'user',
},
],
};
}
`,
},
{
name: 'non-.node.ts file is ignored',
filename: '/tmp/TestHelper.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
{ name: 'Contact', value: 'contact' },
],
default: 'user',
},
],
};
}
`,
},
{
name: 'description assigned from a variable is skipped',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
const desc: INodeTypeDescription = {} as INodeTypeDescription;
export class TestNode implements INodeType {
description = desc;
}
`,
},
{
name: 'node with no properties array is skipped',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
};
}
`,
},
{
name: 'spread element in properties array is skipped gracefully',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
const extraProps = [{ displayName: 'Extra', name: 'extra', type: 'string', default: '' }];
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
...extraProps,
],
};
}
`,
},
{
name: 'options with a spread element are skipped (dynamic options)',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
const extraOption = { name: 'Extra', value: 'extra' };
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
...([extraOption]),
],
default: 'user',
},
],
};
}
`,
},
{
name: 'options with a dynamic name value are skipped',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
const dynamicName = 'Dynamic';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: dynamicName, value: 'dynamic' },
{ name: 'User', value: 'user' },
],
default: 'user',
},
],
};
}
`,
},
],
invalid: [
{
name: 'resource options not sorted alphabetically',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
{ name: 'Contact', value: 'contact' },
{ name: 'Project', value: 'project' },
],
default: 'user',
},
],
};
}
`,
errors: [
{
messageId: 'optionsNotSorted',
data: { displayName: 'Resource', expectedOrder: 'Contact, Project, User' },
},
],
},
{
name: 'operation options not sorted alphabetically',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Update', value: 'update' },
{ name: 'Create', value: 'create' },
{ name: 'Delete', value: 'delete' },
{ name: 'Get', value: 'get' },
],
default: 'get',
},
],
};
}
`,
errors: [
{
messageId: 'optionsNotSorted',
data: { displayName: 'Operation', expectedOrder: 'Create, Delete, Get, Update' },
},
],
},
{
name: 'other options-type parameter not sorted alphabetically',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Model',
name: 'model',
type: 'options',
options: [
{ name: 'GPT-4', value: 'gpt-4' },
{ name: 'Claude', value: 'claude' },
{ name: 'Gemini', value: 'gemini' },
],
default: 'gpt-4',
},
],
};
}
`,
errors: [
{
messageId: 'optionsNotSorted',
data: { displayName: 'Model', expectedOrder: 'Claude, Gemini, GPT-4' },
},
],
},
{
name: 'options-type parameter without displayName falls back to "unknown"',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
{ name: 'Contact', value: 'contact' },
],
default: 'user',
},
],
};
}
`,
errors: [
{
messageId: 'optionsNotSorted',
data: { displayName: 'unknown', expectedOrder: 'Contact, User' },
},
],
},
{
name: 'multiple unsorted parameters each report an error',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{ name: 'User', value: 'user' },
{ name: 'Contact', value: 'contact' },
],
default: 'user',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{ name: 'Update', value: 'update' },
{ name: 'Create', value: 'create' },
],
default: 'create',
},
],
};
}
`,
errors: [
{
messageId: 'optionsNotSorted',
data: { displayName: 'Resource', expectedOrder: 'Contact, User' },
},
{
messageId: 'optionsNotSorted',
data: { displayName: 'Operation', expectedOrder: 'Create, Update' },
},
],
},
],
});

View File

@ -1,129 +0,0 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import {
isNodeTypeClass,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
isFileType,
createRule,
} from '../utils/index.js';
export const OptionsSortedAlphabeticallyRule = createRule({
name: 'options-sorted-alphabetically',
meta: {
type: 'suggestion',
docs: {
description: 'Enforce alphabetical ordering of options arrays in n8n node properties',
},
messages: {
optionsNotSorted:
'Options in "{{ displayName }}" are not sorted alphabetically. Expected order: {{ expectedOrder }}.',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
const analyzeNodeDescription = (descriptionValue: TSESTree.Expression | null): void => {
if (!descriptionValue || descriptionValue.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}
const propertiesProperty = findObjectProperty(descriptionValue, 'properties');
if (
!propertiesProperty?.value ||
propertiesProperty.value.type !== AST_NODE_TYPES.ArrayExpression
) {
return;
}
for (const property of propertiesProperty.value.elements) {
if (!property || property.type !== AST_NODE_TYPES.ObjectExpression) {
continue;
}
const typeProperty = findObjectProperty(property, 'type');
const type = typeProperty ? getStringLiteralValue(typeProperty.value) : null;
if (type !== 'options') {
continue;
}
const optionsProperty = findObjectProperty(property, 'options');
if (
!optionsProperty?.value ||
optionsProperty.value.type !== AST_NODE_TYPES.ArrayExpression
) {
continue;
}
const optionsArray = optionsProperty.value;
// Extract all option names; skip if any name is non-literal (dynamic options)
const names: string[] = [];
let hasDynamicName = false;
for (const option of optionsArray.elements) {
if (!option || option.type !== AST_NODE_TYPES.ObjectExpression) {
hasDynamicName = true;
break;
}
const nameProperty = findObjectProperty(option, 'name');
const name = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
if (name === null) {
hasDynamicName = true;
break;
}
names.push(name);
}
if (hasDynamicName || names.length <= 1) {
continue;
}
const sorted = [...names].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' }),
);
const isSorted = names.every((name, i) => name === sorted[i]);
if (isSorted) {
continue;
}
const displayNameProperty = findObjectProperty(property, 'displayName');
const displayName =
(displayNameProperty ? getStringLiteralValue(displayNameProperty.value) : null) ??
'unknown';
context.report({
node: optionsArray,
messageId: 'optionsNotSorted',
data: {
displayName,
expectedOrder: sorted.join(', '),
},
});
}
};
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
const descriptionProperty = findClassProperty(node, 'description');
if (!descriptionProperty) {
return;
}
analyzeNodeDescription(descriptionProperty.value);
},
};
},
});

View File

@ -1,82 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { RequireCommunityNodeKeywordRule } from './require-community-node-keyword.js';
const ruleTester = new RuleTester();
ruleTester.run('require-community-node-keyword', RequireCommunityNodeKeywordRule, {
valid: [
{
name: 'keywords array contains required keyword',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node-package"] }',
},
{
name: 'keywords array contains required keyword among others',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n", "automation", "n8n-community-node-package", "workflow"] }',
},
{
name: 'non-package.json file is ignored',
filename: 'some-config.json',
code: '{ "name": "n8n-nodes-example" }',
},
{
name: 'nested objects are not checked',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node-package"], "config": { "nested": "value" } }',
},
{
name: 'objects inside arrays (e.g. contributors) are not flagged',
filename: 'package.json',
code: `{
"name": "n8n-nodes-example",
"keywords": ["n8n-community-node-package"],
"contributors": [
{ "name": "Alice", "email": "alice@example.com" },
{ "name": "Bob", "email": "bob@example.com" }
]
}`,
},
],
invalid: [
{
name: 'missing keywords array entirely',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
output:
'{ "name": "n8n-nodes-example", "version": "1.0.0", "keywords": ["n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeywordsArray' }],
},
{
name: 'empty keywords array',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": [] }',
output: '{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeyword' }],
},
{
name: 'keywords array without the required keyword',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n", "automation"] }',
output:
'{ "name": "n8n-nodes-example", "keywords": ["n8n", "automation", "n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeyword' }],
},
{
name: 'keywords array with similar but incorrect keyword',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node"] }',
output:
'{ "name": "n8n-nodes-example", "keywords": ["n8n-community-node", "n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeyword' }],
},
{
name: 'empty package.json object',
filename: 'package.json',
code: '{}',
output: '{ "keywords": ["n8n-community-node-package"] }',
errors: [{ messageId: 'missingKeywordsArray' }],
},
],
});

View File

@ -1,78 +0,0 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import { createRule, findJsonProperty } from '../utils/index.js';
const REQUIRED_KEYWORD = 'n8n-community-node-package';
export const RequireCommunityNodeKeywordRule = createRule({
name: 'require-community-node-keyword',
meta: {
type: 'problem',
docs: {
description:
'Require the "n8n-community-node-package" keyword in community node package.json',
},
fixable: 'code',
messages: {
missingKeyword: `The "keywords" array must include "${REQUIRED_KEYWORD}". This keyword is required for n8n to discover community node packages.`,
missingKeywordsArray: `The package.json must have a "keywords" array containing "${REQUIRED_KEYWORD}".`,
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!context.filename.endsWith('package.json')) {
return {};
}
return {
ObjectExpression(node: TSESTree.ObjectExpression) {
if (node.parent?.type !== AST_NODE_TYPES.ExpressionStatement) {
return;
}
const keywordsProp = findJsonProperty(node, 'keywords');
if (!keywordsProp) {
context.report({
node,
messageId: 'missingKeywordsArray',
fix(fixer) {
const lastProp = node.properties[node.properties.length - 1];
if (!lastProp) {
return fixer.replaceText(node, `{ "keywords": ["${REQUIRED_KEYWORD}"] }`);
}
return fixer.insertTextAfter(lastProp, `, "keywords": ["${REQUIRED_KEYWORD}"]`);
},
});
return;
}
if (keywordsProp.value.type !== AST_NODE_TYPES.ArrayExpression) {
return;
}
const keywordsArray = keywordsProp.value;
const hasRequiredKeyword = keywordsArray.elements.some(
(element) =>
element?.type === AST_NODE_TYPES.Literal && element.value === REQUIRED_KEYWORD,
);
if (!hasRequiredKeyword) {
context.report({
node: keywordsProp,
messageId: 'missingKeyword',
fix(fixer) {
const lastElement = keywordsArray.elements[keywordsArray.elements.length - 1];
if (!lastElement) {
return fixer.replaceText(keywordsArray, `["${REQUIRED_KEYWORD}"]`);
}
return fixer.insertTextAfter(lastElement, `, "${REQUIRED_KEYWORD}"`);
},
});
}
},
};
},
});

View File

@ -1,129 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { RequireContinueOnFailRule } from './require-continue-on-fail.js';
const ruleTester = new RuleTester();
function createNodeWithExecute(executeBody: string): string {
return `
import type { INodeType, INodeTypeDescription, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
${executeBody}
}
}`;
}
ruleTester.run('require-continue-on-fail', RequireContinueOnFailRule, {
valid: [
{
name: 'node with continueOnFail in catch block',
code: createNodeWithExecute(`
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
returnData.push({ json: { result: true } });
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message } });
continue;
}
throw error;
}
}
return [returnData];
`),
},
{
name: 'non-node class with execute method (should be ignored)',
code: `
export class RegularClass {
async execute() {
return [[]];
}
}`,
},
{
name: 'node class without execute method (should be ignored)',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: ['main'],
outputs: ['main'],
properties: [],
};
}`,
},
{
name: 'node extending Node base class with continueOnFail',
code: `
import { Node } from 'n8n-workflow';
export class TestNode extends Node {
async execute() {
try {
// do work
} catch (error) {
if (this.continueOnFail()) {
return [[]];
}
throw error;
}
return [[]];
}
}`,
},
],
invalid: [
{
name: 'node with execute but no continueOnFail',
code: createNodeWithExecute(`
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
returnData.push({ json: { result: true } });
}
return [returnData];
`),
errors: [{ messageId: 'missingContinueOnFail' }],
},
{
name: 'node with try/catch but no continueOnFail check',
code: createNodeWithExecute(`
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
returnData.push({ json: { result: true } });
} catch (error) {
throw error;
}
}
return [returnData];
`),
errors: [{ messageId: 'missingContinueOnFail' }],
},
],
});

View File

@ -1,88 +0,0 @@
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
import { isNodeTypeClass } from '../utils/index.js';
import { createRule } from '../utils/rule-creator.js';
/** Keys that are not child AST nodes (back-references, metadata). */
const NON_CHILD_KEYS = new Set(['parent', 'loc', 'range', 'start', 'end', 'tokens', 'comments']);
/**
* Recursively checks whether any descendant of the given AST node is a
* `this.continueOnFail()` call expression.
*/
function containsContinueOnFailCall(node: TSESTree.Node): boolean {
if (
node.type === AST_NODE_TYPES.CallExpression &&
node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.object.type === AST_NODE_TYPES.ThisExpression &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
node.callee.property.name === 'continueOnFail'
) {
return true;
}
for (const key of Object.keys(node)) {
if (NON_CHILD_KEYS.has(key)) continue;
const value = (node as unknown as Record<string, unknown>)[key];
if (Array.isArray(value)) {
for (const child of value) {
if (child && typeof child === 'object' && 'type' in child) {
if (containsContinueOnFailCall(child as TSESTree.Node)) {
return true;
}
}
}
} else if (value && typeof value === 'object' && 'type' in value) {
if (containsContinueOnFailCall(value as TSESTree.Node)) {
return true;
}
}
}
return false;
}
export const RequireContinueOnFailRule = createRule({
name: 'require-continue-on-fail',
meta: {
type: 'problem',
docs: {
description: 'Require continueOnFail() handling in execute() methods of node classes',
},
messages: {
missingContinueOnFail:
'execute() method must handle this.continueOnFail() for proper error handling. ' +
'Wrap item processing in a try/catch and check this.continueOnFail() in the catch block.',
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) {
return;
}
for (const member of node.body.body) {
if (
member.type !== AST_NODE_TYPES.MethodDefinition ||
member.key.type !== AST_NODE_TYPES.Identifier ||
member.key.name !== 'execute'
) {
continue;
}
if (member.value.body && !containsContinueOnFailCall(member.value.body)) {
context.report({
node: member.key,
messageId: 'missingContinueOnFail',
});
}
}
},
};
},
});

View File

@ -1,67 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { RequireMitLicenseRule } from './require-mit-license.js';
const ruleTester = new RuleTester();
ruleTester.run('require-mit-license', RequireMitLicenseRule, {
valid: [
{
name: 'license is MIT',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "license": "MIT" }',
},
{
name: 'non-package.json file is ignored',
filename: 'some-config.json',
code: '{ "name": "n8n-nodes-example", "license": "Apache-2.0" }',
},
{
name: 'nested objects are not checked',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "license": "MIT", "config": { "license": "Apache-2.0" } }',
},
{
name: 'objects inside arrays are not flagged',
filename: 'package.json',
code: `{
"name": "n8n-nodes-example",
"license": "MIT",
"contributors": [
{ "name": "Alice" },
{ "name": "Bob" }
]
}`,
},
],
invalid: [
{
name: 'missing license field',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
output: '{ "name": "n8n-nodes-example", "version": "1.0.0", "license": "MIT" }',
errors: [{ messageId: 'missingLicense' }],
},
{
name: 'empty package.json object',
filename: 'package.json',
code: '{}',
output: '{ "license": "MIT" }',
errors: [{ messageId: 'missingLicense' }],
},
{
name: 'wrong license value',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "license": "Apache-2.0" }',
output: '{ "name": "n8n-nodes-example", "license": "MIT" }',
errors: [{ messageId: 'wrongLicense' }],
},
{
name: 'case-mismatched license value',
filename: 'package.json',
code: '{ "name": "n8n-nodes-example", "license": "mit" }',
output: '{ "name": "n8n-nodes-example", "license": "MIT" }',
errors: [{ messageId: 'wrongLicense' }],
},
],
});

View File

@ -1,65 +0,0 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import { createRule, findJsonProperty, getStringLiteralValue } from '../utils/index.js';
const REQUIRED_LICENSE = 'MIT';
export const RequireMitLicenseRule = createRule({
name: 'require-mit-license',
meta: {
type: 'problem',
docs: {
description: 'Require the "license" field in community node package.json to be "MIT"',
},
fixable: 'code',
messages: {
missingLicense: `The package.json must have a "license" field set to "${REQUIRED_LICENSE}".`,
wrongLicense: `The "license" field must be "${REQUIRED_LICENSE}".`,
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!context.filename.endsWith('package.json')) {
return {};
}
return {
ObjectExpression(node: TSESTree.ObjectExpression) {
if (node.parent?.type !== AST_NODE_TYPES.ExpressionStatement) {
return;
}
const licenseProp = findJsonProperty(node, 'license');
if (!licenseProp) {
context.report({
node,
messageId: 'missingLicense',
fix(fixer) {
const lastProp = node.properties[node.properties.length - 1];
if (!lastProp) {
return fixer.replaceText(node, `{ "license": "${REQUIRED_LICENSE}" }`);
}
return fixer.insertTextAfter(lastProp, `, "license": "${REQUIRED_LICENSE}"`);
},
});
return;
}
if (getStringLiteralValue(licenseProp.value) === REQUIRED_LICENSE) {
return;
}
context.report({
node: licenseProp.value,
messageId: 'wrongLicense',
fix(fixer) {
return fixer.replaceText(licenseProp.value, `"${REQUIRED_LICENSE}"`);
},
});
},
};
},
});

View File

@ -1,227 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { RequireParamDefaultRule } from './require-param-default.js';
const ruleTester = new RuleTester();
function createNodeCode(properties: string): string {
return `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
group: ['input'],
version: 1,
description: 'A test node',
defaults: { name: 'Test Node' },
inputs: [],
outputs: [],
properties: [
${properties}
],
};
}
`;
}
ruleTester.run('require-param-default', RequireParamDefaultRule, {
valid: [
{
name: 'class that does not implement INodeType',
filename: '/tmp/TestNode.node.ts',
code: `
export class NotANode {
description = {
properties: [{ displayName: 'Field', name: 'field', type: 'string' }],
};
}
`,
},
{
name: 'param-shaped object outside description (e.g. in a method) is ignored',
filename: '/tmp/TestNode.node.ts',
code: `
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
export class TestNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
properties: [],
};
methods = {
loadOptions: {
async getThings() {
const config = { displayName: 'X', name: 'x', type: 'string' };
return [config];
},
},
};
}
`,
},
{
name: 'non .node.ts file is ignored',
filename: '/tmp/helper.ts',
code: `
export class TestNode {
description = {
properties: [{ displayName: 'Field', name: 'field', type: 'string' }],
};
}
`,
},
{
name: 'string parameter with default',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Field',
name: 'field',
type: 'string',
default: '',
},
`),
},
{
name: 'options parameter with default',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [{ name: 'Create', value: 'create' }],
default: 'create',
},
`),
},
{
name: 'notice parameter with empty default',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Some notice',
name: 'notice',
type: 'notice',
default: '',
},
`),
},
{
name: 'options-array entries (name/value, no displayName) are not parameters',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [{ name: 'Create', value: 'create', type: 'whatever' }],
default: 'create',
},
`),
},
{
name: 'object with name and type but no displayName is not a parameter',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Routing',
name: 'routing',
type: 'string',
default: '',
routing: { request: { method: 'GET', name: 'x', type: 'y' } },
},
`),
},
{
name: 'nested collection options all have defaults',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
default: {},
options: [
{
displayName: 'Limit',
name: 'limit',
type: 'number',
default: 50,
},
],
},
`),
},
],
invalid: [
{
name: 'string parameter missing default',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Field',
name: 'field',
type: 'string',
},
`),
errors: [{ messageId: 'missingDefault', data: { name: 'field' } }],
},
{
name: 'notice parameter missing default',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Some notice',
name: 'notice',
type: 'notice',
},
`),
errors: [{ messageId: 'missingDefault', data: { name: 'notice' } }],
},
{
name: 'nested collection option missing default',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
default: {},
options: [
{
displayName: 'Limit',
name: 'limit',
type: 'number',
},
],
},
`),
errors: [{ messageId: 'missingDefault', data: { name: 'limit' } }],
},
{
name: 'multiple parameters missing defaults',
filename: '/tmp/TestNode.node.ts',
code: createNodeCode(`
{
displayName: 'First',
name: 'first',
type: 'string',
},
{
displayName: 'Second',
name: 'second',
type: 'boolean',
},
`),
errors: [
{ messageId: 'missingDefault', data: { name: 'first' } },
{ messageId: 'missingDefault', data: { name: 'second' } },
],
},
],
});

View File

@ -1,89 +0,0 @@
/**
* Requires every node parameter to declare a `default` property.
*
* A parameter is detected structurally: an object literal inside a node's
* `description` with `displayName`, `name`, and `type` all set to string
* literals — the shape every `INodeProperties` entry has. This avoids
* hardcoding the `NodePropertyTypes` union (which would silently go stale as
* new types are added) and naturally excludes `options`-array entries, which
* carry `name`/`value` but no `displayName`/`type`.
*
* Without a `default`, n8n cannot reliably initialise the parameter's value,
* which leads to inconsistent runtime behaviour in the editor and on execution.
*/
import { simpleTraverse } from '@typescript-eslint/typescript-estree';
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import {
createRule,
findClassProperty,
findObjectProperty,
getStringLiteralValue,
isFileType,
isNodeTypeClass,
} from '../utils/index.js';
/** Property keys an `INodeProperties` parameter always carries as string literals. */
const REQUIRED_PARAM_KEYS = ['displayName', 'name', 'type'] as const;
/** Returns true when the object literal has the shape of a node parameter. */
function isNodeParameter(node: TSESTree.ObjectExpression): boolean {
return REQUIRED_PARAM_KEYS.every((key) => {
const property = findObjectProperty(node, key);
return property !== null && getStringLiteralValue(property.value) !== null;
});
}
export const RequireParamDefaultRule = createRule({
name: 'require-param-default',
meta: {
type: 'problem',
docs: {
description: 'Require every node parameter to declare a default value.',
},
messages: {
missingDefault:
"Node parameter '{{ name }}' is missing a `default` value. Add a `default` property so the parameter initialises consistently.",
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) {
return {};
}
const reportIfMissingDefault = (node: TSESTree.ObjectExpression): void => {
if (!isNodeParameter(node) || findObjectProperty(node, 'default') !== null) {
return;
}
const nameProperty = findObjectProperty(node, 'name');
context.report({
node,
messageId: 'missingDefault',
data: { name: getStringLiteralValue(nameProperty?.value ?? null) ?? '' },
});
};
return {
// Scope to an INodeType class's `description`, then walk only that
// subtree, so helper classes and object literals elsewhere in the file
// (e.g. in method bodies) that share the parameter shape are ignored.
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) return;
const description = findClassProperty(node, 'description');
if (description?.value?.type !== AST_NODE_TYPES.ObjectExpression) return;
simpleTraverse(description.value, {
enter(child) {
if (child.type === AST_NODE_TYPES.ObjectExpression) {
reportIfMissingDefault(child);
}
},
});
},
};
},
});

View File

@ -14,7 +14,8 @@ them. Some are conditional on context availability.
Persist a dependency-aware task plan for detached multi-step execution. For
initial plan-worthy work, the orchestrator loads the `planning` skill, performs
discovery with normal domain tools, then calls `create-tasks` with
discovery with normal domain tools, loads `create-tasks` via `load_tool`, then
calls `create-tasks` with
`planningContext.source: "planning-skill"`. For
`<planned-task-follow-up type="replan">` turns, use
`planningContext.source: "replan"` when multiple dependent tasks still need
@ -723,6 +724,8 @@ only the domain tools wired into that agent.
| Tool Category | Orchestrator | Specialized background agents |
|---------------|:---:|:---:|
| Orchestration tools (`create-tasks`, etc.) | ✅ | ❌ |
| Docs search (`n8n-docs`) | ✅ (search/load) | ❌ |
| Eval tools (`evals`) | ✅ (search/load) | ❌ |
| Workflow tools | ✅ | ✅ (eval-setup) |
| Execution tools | ✅ | ❌ |
| Credential tools | ✅ | ✅ (eval-setup — setup only) |

View File

@ -206,7 +206,7 @@ Every run produces:
After every successful build, the eval grades the workflow JSON against the binary-check rubric in `binaryChecks/checks/`. Each named check is yes/no with a structured N/A for "no subject to evaluate in this workflow" (e.g. an agent-only check on a workflow with no agent).
The 28 checks are grouped into 7 WHAT-side rubric dimensions (the 8th, `execution_outcome`, is served by the existing execution verifier):
The 28 checks are grouped into 8 WHAT-side rubric dimensions (the 9th, `execution_outcome`, is served by the existing execution verifier):
| Dimension | Checks |
|---|---|
@ -214,8 +214,9 @@ The 28 checks are grouped into 7 WHAT-side rubric dimensions (the 8th, `executio
| `connection_topology` | 4 — graph reachability, branch wiring, multi-item handling |
| `parameter_correctness` | 8 — node config, expressions, field references |
| `intent_match` | 1 — workflow fulfills the user's request |
| `communication` | 1 — agent narration honestly describes the changes made |
| `ai_nodes` | 6 — agent / memory / vector-store / tool wiring |
| `nodes_craftsmanship` | 3 — naming, no-code preference, response honesty |
| `nodes_craftsmanship` | 2 — naming, no-code preference |
| `security` | 2 — hardcoded credentials, inbound auth defaults |
The signal surfaces in:
@ -228,7 +229,7 @@ Operational details:
- Checks run **once per built workflow**, not per scenario — every scenario row in LangSmith carries the same outcomes for its build.
- Failures don't flip `scenario_pass`; they're independent signals per the rubric design.
- LLM checks (`fulfills_user_request`, `valid_data_flow`, `correct_node_operations`, `handles_multiple_items`, `descriptive_node_names`, `response_matches_workflow_changes`) reuse the same Sonnet model as the verifier — auto-skipped (N/A) when no Anthropic key is set.
- LLM checks (`fulfills_user_request`, `valid_data_flow`, `correct_node_operations`, `handles_multiple_items`, `descriptive_node_names`, `response_describes_changes_accurately`) reuse the same Sonnet model as the verifier — auto-skipped (N/A) when no Anthropic key is set.
### Build expectations (per test case)

View File

@ -1,5 +1,6 @@
import type { ConversationTurn, TranscriptTurn } from '../types';
import {
agentTurnsAsText,
conversationUserTurnsAsText,
lastAgentText,
perTurnToolCallCounts,
@ -30,6 +31,35 @@ describe('userTurnsAsText', () => {
});
});
describe('agentTurnsAsText', () => {
it('returns empty string on empty transcript', () => {
expect(agentTurnsAsText([])).toBe('');
});
it('returns the lone narration as plain text when only one turn narrates', () => {
const transcript: TranscriptTurn[] = [
{ userMessage: 'build a webhook', steps: [{ kind: 'agent-text', text: 'Added a webhook.' }] },
];
expect(agentTurnsAsText(transcript)).toBe('Added a webhook.');
});
it('numbers narration by conversation turn, dropping turns with no agent text', () => {
const transcript: TranscriptTurn[] = [
{ userMessage: 'build it', steps: [{ kind: 'agent-text', text: 'Added a webhook.' }] },
{
userMessage: 'tweak',
steps: [{ kind: 'tool-call', toolName: 'build-workflow', args: {} }],
},
{ userMessage: 'add a filter', steps: [{ kind: 'agent-text', text: 'Added a filter.' }] },
];
// Turn 2 has no narration, so it's skipped — but turn 3 keeps its turn number
// so each claim still aligns with the user turn that prompted it.
expect(agentTurnsAsText(transcript)).toBe(
'Turn 1: Added a webhook.\n\nTurn 3: Added a filter.',
);
});
});
describe('conversationUserTurnsAsText', () => {
it('returns empty string on empty conversation', () => {
expect(conversationUserTurnsAsText([])).toBe('');

View File

@ -0,0 +1,16 @@
import { effectiveTimeoutMs } from '../harness/runner';
describe('effectiveTimeoutMs', () => {
it('gives complex cases 1.5x the base budget', () => {
expect(effectiveTimeoutMs('complex', 900_000)).toBe(1_350_000);
});
it('keeps the base budget for simple and medium cases', () => {
expect(effectiveTimeoutMs('simple', 900_000)).toBe(900_000);
expect(effectiveTimeoutMs('medium', 900_000)).toBe(900_000);
});
it('keeps the base budget when complexity is missing', () => {
expect(effectiveTimeoutMs(undefined, 900_000)).toBe(900_000);
});
});

View File

@ -1,5 +1,6 @@
import type { WorkflowTestCaseWithFile } from '../data/workflows';
import { planPush } from '../langtracer/push';
import { planPush, toUpdatePatch } from '../langtracer/push';
import type { LangTracerCreateCaseBody } from '../langtracer/to-exported';
function item(fileSlug: string, overrides: Record<string, unknown> = {}): WorkflowTestCaseWithFile {
return {
@ -61,7 +62,7 @@ describe('planPush', () => {
expect(plan.toCreate).toEqual([]);
});
it('treats a scenario-only difference as unchanged so re-runs converge', () => {
it('treats a scenario-only difference as an update (PATCH reconciles scenarios by name)', () => {
const plan = planPush(
[
item('c', {
@ -79,6 +80,32 @@ describe('planPush', () => {
},
{ c: 5 },
);
expect(plan.toUpdate).toHaveLength(1);
expect(plan.toUpdate[0].id).toBe(5);
expect(plan.unchanged).toEqual([]);
});
it('treats removed scenarios as an update (disk case went process/outcome-only)', () => {
const plan = planPush(
[item('c')],
{
'c.json': body({
executionScenarios: [{ name: 'a', dataSetup: 's', successCriteria: 'ok' }],
}),
},
{ c: 5 },
);
expect(plan.toUpdate).toHaveLength(1);
expect(plan.unchanged).toEqual([]);
});
it('treats identical scenarios as unchanged so re-pushes converge', () => {
const scenarios = [{ name: 'a', description: 'd', dataSetup: 's', successCriteria: 'ok' }];
const plan = planPush(
[item('c', { executionScenarios: scenarios })],
{ 'c.json': body({ executionScenarios: scenarios }) },
{ c: 5 },
);
expect(plan.unchanged.map((c) => c.fileSlug)).toEqual(['c']);
expect(plan.toUpdate).toEqual([]);
});
@ -135,3 +162,38 @@ describe('planPush', () => {
expect(plan.toUpdate).toEqual([]);
});
});
describe('toUpdatePatch', () => {
function createBody(overrides: Partial<LangTracerCreateCaseBody> = {}): LangTracerCreateCaseBody {
return {
name: 'c',
setKind: 'regression',
synthetic: true,
suiteId: 7,
evalComplexity: 'simple',
evalTags: ['build'],
...overrides,
};
}
it('drops the create-only fields', () => {
const patch = toUpdatePatch(createBody());
expect(patch).not.toHaveProperty('suiteId');
expect(patch).not.toHaveProperty('synthetic');
expect(patch.name).toBe('c');
});
it('sends an explicit empty scenarios list when the case has none, so a PATCH deletes stale rows', () => {
// A case converted to process/outcome-only has no scenarios key; a partial
// PATCH without it would leave the server's old scenario rows in place and
// every later push would re-detect the same drift.
const patch = toUpdatePatch(createBody());
expect(patch.scenarios).toEqual([]);
});
it('keeps the mapped scenarios when the case has them', () => {
const scenarios = [{ name: 'a', successCriteria: 'ok' }];
const patch = toUpdatePatch(createBody({ scenarios }));
expect(patch.scenarios).toEqual(scenarios);
});
});

View File

@ -216,6 +216,120 @@ describe('buildVerificationArtifact', () => {
expect(logger.info).not.toHaveBeenCalled();
});
it('routes to the entry point when the build saved a sub-workflow first', () => {
// Sub-workflow tool-result arrives first → build.workflowId is the sub.
// Executing it directly starts once with an empty payload; the caller's
// entry point is the trigger-bearing sibling.
const logger = { info: vi.fn() };
const sub: WorkflowResponse = {
id: 'process-order-sub',
name: 'Process Order',
active: false,
versionId: 'v1',
nodes: [
{
id: 'a',
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
};
const main: WorkflowResponse = {
id: 'orders-main',
name: 'Order Intake',
active: false,
versionId: 'v1',
nodes: [
{
id: 'b',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'c',
name: 'Run Process Order',
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1,
position: [0, 0],
parameters: { workflowId: { value: 'process-order-sub' } },
},
],
connections: {},
};
const selected = selectScenarioWorkflowId(
{ ...scenario, name: 'three-orders-three-runs', dataSetup: 'Process Order runs three times' },
'process-order-sub',
[sub, main],
logger as never,
);
expect(selected).toBe('orders-main');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('orders-main'));
});
it('demotes executeWorkflow-referenced candidates even when scenario tokens favor the sub', () => {
const logger = { info: vi.fn() };
const sub: WorkflowResponse = {
id: 'sub-with-own-trigger',
name: 'Process Order',
active: false,
versionId: 'v1',
nodes: [
{
id: 'a',
name: 'Process Order Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
};
const main: WorkflowResponse = {
id: 'orders-main',
name: 'Order Intake',
active: false,
versionId: 'v1',
nodes: [
{
id: 'b',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'c',
name: 'Run Sub',
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1,
position: [0, 0],
parameters: { workflowId: 'sub-with-own-trigger' },
},
],
connections: {},
};
const selected = selectScenarioWorkflowId(
{ ...scenario, name: 'process-order-runs', dataSetup: 'Process Order handles each order' },
'orders-main',
[main, sub],
logger as never,
);
expect(selected).toBe('orders-main');
});
it('labels Filter branches with downstream node names so verifier can tell where items went', () => {
const wf: WorkflowResponse = {
id: 'w1',

View File

@ -79,12 +79,12 @@ describe('CheckOutcome → LangSmith Feedback projection', () => {
it('drops N/A even when comment is present', () => {
const single: CheckOutcome[] = [
{
name: 'response_matches_workflow_changes',
name: 'response_describes_changes_accurately',
description: 'LLM check',
kind: 'llm',
dimension: 'nodes_craftsmanship',
dimension: 'communication',
status: 'n_a',
comment: 'Skipped: no agent text response',
comment: 'Skipped: no agent narration available',
},
];
expect(extractCheckFeedback(single)).toEqual([]);

View File

@ -1,6 +1,6 @@
import type { Mock } from 'vitest';
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import { N8nApiError, type N8nClient, type WorkflowResponse } from '../clients/n8n-client';
import { buildAgentOutcome } from '../outcome/workflow-discovery';
import type { EventOutcome } from '../types';
@ -101,3 +101,82 @@ describe('buildAgentOutcome', () => {
expect(listWorkflows).not.toHaveBeenCalled();
});
});
describe('buildAgentOutcome phantom-id handling', () => {
/** Client whose getWorkflow resolves per id: a WorkflowResponse, or a thrown error. */
function clientWith(byId: Record<string, WorkflowResponse | Error>): N8nClient {
return {
getWorkflow: vi.fn(async (id: string) => {
const entry = byId[id];
if (entry === undefined) throw new N8nApiError(`Workflow ${id} not found`, 404);
if (entry instanceof Error) throw entry;
return await Promise.resolve(entry);
}),
listExecutions: vi.fn().mockResolvedValue([]),
} as unknown as N8nClient;
}
function outcomeWithIds(workflowIds: string[]): EventOutcome {
return { ...emptyEventOutcome, workflowIds };
}
it('drops phantom ids (404) so a real workflow becomes workflowsCreated[0]', async () => {
// An agent-invented id echoed by a failed build-workflow bind, then the real save.
const client = clientWith({ 'real-id': workflow('real-id') });
const outcome = await buildAgentOutcome(client, outcomeWithIds(['pokemon-digest', 'real-id']));
expect(outcome.workflowsCreated.map((wf) => wf.id)).toEqual(['real-id']);
expect(outcome.workflowJsons.map((wf) => wf.id)).toEqual(['real-id']);
});
it('drops inaccessible ids (403)', async () => {
const client = clientWith({
forbidden: new N8nApiError('Workflow forbidden not accessible', 403),
'real-id': workflow('real-id'),
});
const outcome = await buildAgentOutcome(client, outcomeWithIds(['forbidden', 'real-id']));
expect(outcome.workflowsCreated.map((wf) => wf.id)).toEqual(['real-id']);
});
it('keeps a stub for transport-level fetch failures, ordered after real workflows', async () => {
const client = clientWith({
flaky: new TypeError('fetch failed'),
'real-id': workflow('real-id'),
});
const outcome = await buildAgentOutcome(client, outcomeWithIds(['flaky', 'real-id']));
expect(outcome.workflowsCreated.map((wf) => wf.id)).toEqual(['real-id', 'flaky']);
expect(outcome.workflowsCreated[1].name).toBe('(fetch failed)');
expect(outcome.workflowJsons.map((wf) => wf.id)).toEqual(['real-id']);
});
it('returns no workflows when every candidate id is phantom', async () => {
const outcome = await buildAgentOutcome(
clientWith({}),
outcomeWithIds(['made-up', 'also-fake']),
);
expect(outcome.workflowsCreated).toEqual([]);
});
it('logs dropped phantom ids when a logger is provided', async () => {
const logger = { warn: vi.fn() };
const client = clientWith({ 'real-id': workflow('real-id') });
await buildAgentOutcome(
client,
outcomeWithIds(['phantom-slug', 'real-id']),
undefined,
undefined,
{
logger,
},
);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('phantom-slug'));
});
});

View File

@ -1,5 +1,6 @@
import { createWorkspaceTools } from '@n8n/agents';
import { INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST } from '../../src/workspace/lazy-runtime-workspace';
import { IGNORED_WORKSPACE_TOOLS, REPLAYED_WORKSPACE_TOOL_ARGS } from '../harness/langsmith-seed';
// The seed reconstructor replays @n8n/agents workspace file ops by matching tool
@ -32,4 +33,43 @@ describe('workspace tool contract (@n8n/agents drift guard)', () => {
}
}
});
it('exposes only the Instance AI allowlisted tools to agents', () => {
const liveTools = createWorkspaceTools({
filesystem: {},
sandbox: {
executeCommand: vi.fn().mockResolvedValue({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
executionTimeMs: 0,
}),
},
} as unknown as Parameters<typeof createWorkspaceTools>[0]);
const liveNames = new Set(liveTools.map((tool) => tool.name));
for (const name of INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST) {
expect(liveNames.has(name), name).toBe(true);
}
expect(liveNames.size).toBeGreaterThanOrEqual(INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST.size);
});
it('does not expose removed filesystem helpers via the allowlist', () => {
const removed = [
'workspace_append_file',
'workspace_list_files',
'workspace_file_stat',
'workspace_mkdir',
'workspace_rmdir',
'workspace_delete_file',
'workspace_copy_file',
'workspace_move_file',
];
for (const name of removed) {
expect(INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST.has(name)).toBe(false);
// Historical seeds may still replay these — they remain in @n8n/agents.
expect(liveByName.has(name), `${name} should still exist for seed replay`).toBe(true);
}
});
});

View File

@ -5,7 +5,8 @@ import type { WorkflowResponse } from '../../clients/n8n-client';
import { parseJudgeVerdict, REASONING_FIRST_SUFFIX } from '../../utils/llm-judge';
import type { BinaryCheck, BinaryCheckContext, CheckDimension } from '../types';
const DEFAULT_TIMEOUT_MS = 30_000;
// Headroom for the multi-turn honesty check, which reasons over every claim across all turns.
const DEFAULT_TIMEOUT_MS = 60_000;
function isLlmCheckTimeout(error: unknown, checkName: string): error is Error {
return (
@ -73,7 +74,7 @@ export function createLlmCheck(options: LlmCheckOptions): BinaryCheck {
.replace('{agentTextResponse}', ctx.agentTextResponse ?? '')
.replace(
'{workflowBefore}',
ctx.existingWorkflow ? JSON.stringify(ctx.existingWorkflow, null, 2) : '{}',
ctx.workflowBefore ? JSON.stringify(ctx.workflowBefore, null, 2) : '{}',
);
const agent = getOrCreateAgent(options.name, ctx.modelId, systemPrompt);

View File

@ -32,7 +32,7 @@ import { noHardcodedCredentials } from './no-hardcoded-credentials';
import { noInvalidFromAi } from './no-invalid-from-ai';
import { noUnnecessaryCodeNodes } from './no-unnecessary-code-nodes';
import { noUnreachableNodes } from './no-unreachable-nodes';
import { responseMatchesWorkflowChanges } from './response-matches-workflow-changes';
import { responseDescribesChangesAccurately } from './response-describes-changes-accurately';
import { switchFallbackOutputEnabled } from './switch-fallback-output-enabled';
import { toolsHaveParameters } from './tools-have-parameters';
import { validDataFlow } from './valid-data-flow';
@ -71,6 +71,8 @@ export const PARAMETER_CORRECTNESS_CHECKS: BinaryCheck[] = [
export const INTENT_MATCH_CHECKS: BinaryCheck[] = [fulfillsUserRequest];
export const COMMUNICATION_CHECKS: BinaryCheck[] = [responseDescribesChangesAccurately];
export const AI_NODES_CHECKS: BinaryCheck[] = [
agentHasDynamicPrompt,
agentHasLanguageModel,
@ -84,7 +86,6 @@ export const NODES_CRAFTSMANSHIP_CHECKS: BinaryCheck[] = [
noUnnecessaryCodeNodes,
codeNodeNoHttpRequests,
descriptiveNodeNames,
responseMatchesWorkflowChanges,
];
export const EFFICIENCY_CHECKS: BinaryCheck[] = [noExcessiveBuildFailures];
@ -96,6 +97,7 @@ export const ALL_CHECKS: BinaryCheck[] = [
...CONNECTION_TOPOLOGY_CHECKS,
...PARAMETER_CORRECTNESS_CHECKS,
...INTENT_MATCH_CHECKS,
...COMMUNICATION_CHECKS,
...AI_NODES_CHECKS,
...NODES_CRAFTSMANSHIP_CHECKS,
...EFFICIENCY_CHECKS,

View File

@ -0,0 +1,53 @@
import { createLlmCheck } from './create-llm-check';
// Communication/honesty check: does the agent's narration over-claim relative to
// what it actually built? Fails only on false/overstated claims — never on
// omissions (agents reasonably summarize). Completeness is a separate concern,
// covered by `fulfills_user_request`.
export const responseDescribesChangesAccurately = createLlmCheck({
name: 'response_describes_changes_accurately',
description:
"Agent's narration makes no false or overstated claims about the changes it actually made (over-claiming only — unmentioned changes are fine; completeness is covered by fulfills_user_request)",
dimension: 'communication',
systemPrompt: `You are a strict evaluator checking whether an AI assistant's narration honestly describes the changes it made to an n8n workflow.
The narration may span multiple conversation turns (numbered "Turn N:"). You are given:
- The workflow at the START of the conversation (often empty — a brand-new workflow built over the conversation)
- The FINAL workflow after the conversation
- The agent's narration across the whole conversation
Your task:
1. Read the agent's narration and identify every specific claim about workflow changes (e.g., "I added a Slack node", "I configured the HTTP Request node to use POST", "I connected the trigger to the filter node").
2. Determine what actually changed between the START and FINAL workflows. When START is empty, every node and connection in FINAL is something the agent added.
3. Verify each claim against the actual net change. Reason across turns: a node added in one turn and removed in a later turn is correctly absent from FINAL — judge claims against the net effect of the whole conversation, not any single turn.
4. Pass ONLY if every specific claim is reflected in the actual changes.
Rules:
- If the agent claims to have added a node, that node must be in the FINAL workflow.
- If the agent claims a specific configuration, verify the FINAL node has it.
- If the agent claims connections between nodes, verify they exist in the FINAL workflow.
- Treat "added", "configured", "set up", and "updated" claims as accurate as long as the FINAL workflow reflects them. START may be empty, so do not penalize "updated" wording just because the node appears new.
- Do NOT penalize the agent for changes it made but did not mention. Omissions are fine; this check fails only on claims that are false or overstated.
- Ignore vague statements that make no specific claim (e.g., "I built a workflow for you").
- Treat SDK helper claims such as \`nodeJson(sourceNode, 'field.path')\` as equivalent to the generated workflow expression \`$('Source Node').item.json.field.path\` when the referenced node and JSON path match semantically.
- If the narration contains NO specific claims about workflow changes, pass (nothing to verify).
- A single verifiably false claim means fail.`,
humanTemplate: `User Request: {userPrompt}
Agent's narration (across the conversation):
{agentTextResponse}
Workflow at the START of the conversation:
{workflowBefore}
Final workflow:
{generatedWorkflow}
Verify each specific claim in the narration against the net change from START to FINAL. If there are no specific claims, pass.`,
skipIf: (_workflow, ctx) => {
if (!ctx.agentTextResponse) {
return 'Skipped: no agent narration available';
}
return undefined;
},
});

View File

@ -1,48 +0,0 @@
import { createLlmCheck } from './create-llm-check';
// Scores agent communication honesty rather than workflow craft; revisit
// its dimension when conversation-side rubric checks land.
export const responseMatchesWorkflowChanges = createLlmCheck({
name: 'response_matches_workflow_changes',
description: "Agent's text response accurately describes the actual workflow changes",
dimension: 'nodes_craftsmanship',
systemPrompt: `You are a strict evaluator checking whether an AI assistant's text response accurately describes the changes it made to an n8n workflow.
You are given:
- The workflow BEFORE the agent's turn (may be empty for new workflows)
- The workflow AFTER the agent's turn
- The agent's text response describing what it did
Your task:
1. Read the agent's text response and identify all claims about workflow changes (e.g., "I added a Slack node", "I configured the HTTP Request node to use POST method", "I connected the trigger to the filter node").
2. Compare the before and after workflow JSONs to determine what actually changed.
3. Verify each claim against the actual diff between before and after workflows.
4. Pass ONLY if every claim the agent makes is reflected in the actual changes.
Rules:
- If the agent claims to have added a node, that node must be in the AFTER workflow but NOT in the BEFORE workflow (or be newly configured).
- If the agent claims a specific configuration change, verify it differs between before and after.
- If the agent claims connections between nodes, verify those connections exist in the AFTER workflow.
- Ignore general/vague statements that don't make specific claims (e.g., "I built a workflow for you").
- Treat SDK helper claims such as \`nodeJson(sourceNode, 'field.path')\` as equivalent to the generated workflow expression \`$('Source Node').item.json.field.path\` when the referenced node and JSON path match semantically.
- If the agent's response contains NO specific claims about workflow changes, pass (nothing to verify).
- A single verifiably false claim means fail.`,
humanTemplate: `User Request: {userPrompt}
Agent's Text Response:
{agentTextResponse}
Workflow BEFORE agent's turn:
{workflowBefore}
Workflow AFTER agent's turn:
{generatedWorkflow}
Compare the before and after workflows, then verify each specific claim the agent makes. If there are no specific claims, pass.`,
skipIf: (_workflow, ctx) => {
if (!ctx.agentTextResponse) {
return 'Skipped: no agent text response available';
}
return undefined;
},
});

View File

@ -38,10 +38,14 @@ export interface BinaryCheckContext {
modelId?: string;
/** Timeout in ms for LLM checks. Defaults to 30_000. */
timeoutMs?: number;
/** The agent's text response (used by response-matches-workflow-changes check) */
/** The agent's narration across the conversation (used by the response_describes_changes_accurately check) */
agentTextResponse?: string;
/** The workflow before the agent's turn (used by response-matches-workflow-changes check) */
existingWorkflow?: WorkflowResponse;
/**
* The workflow at the start of the conversation, before the agent's changes
* (used by response_describes_changes_accurately). Empty for from-scratch
* builds — set only when a conversation starts from an existing workflow.
*/
workflowBefore?: WorkflowResponse;
/** Per-test-case annotations forwarded from fixtures. Used by checks that opt into fixture-side overrides. */
annotations?: Record<string, unknown>;
/**
@ -63,6 +67,7 @@ export const CHECK_DIMENSIONS = [
'connection_topology',
'parameter_correctness',
'intent_match',
'communication',
'ai_nodes',
'nodes_craftsmanship',
'efficiency',

View File

@ -72,6 +72,7 @@ import {
executeScenario,
cleanupBuild,
runWorkflowChecks,
effectiveTimeoutMs,
runWorkflowTestCase,
runWithConcurrency,
type BuildResult,
@ -499,6 +500,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
return {
evaluation: { totalRuns: 0, testCases: [] },
experimentName: '',
experimentUrl: undefined,
outcome: { kind: 'no_baseline' },
slugByTestCase: new Map(),
};
@ -549,7 +551,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
| 'seedFile'
| 'priorConversation'
| 'seedThread'
>;
> & { timeoutMs: number };
interface LaneState {
runner: Lane;
activeBuilds: number;
@ -560,6 +562,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
scenario: ExecutionScenario;
workflowJsons: BuildResult['workflowJsons'];
buildTrace?: BuildResult['buildTrace'];
timeoutMs: number;
}) => Promise<Awaited<ReturnType<typeof executeScenario>>>;
}
@ -581,7 +584,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
priorConversation: buildArgs.priorConversation,
seedThread: buildArgs.seedThread,
createdCredentialIds: lane.createdCredentialIds,
timeoutMs: args.timeoutMs,
timeoutMs: buildArgs.timeoutMs,
preRunWorkflowIds: lane.preRunWorkflowIds,
claimedWorkflowIds: lane.claimedWorkflowIds,
logger,
@ -600,6 +603,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
scenario: ExecutionScenario;
workflowJsons: BuildResult['workflowJsons'];
buildTrace?: BuildResult['buildTrace'];
timeoutMs: number;
}) =>
await executeScenario(
lane.client,
@ -607,7 +611,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
execArgs.scenario,
execArgs.workflowJsons,
logger,
args.timeoutMs,
execArgs.timeoutMs,
undefined,
execArgs.buildTrace,
args.pinAiRoots,
@ -725,6 +729,12 @@ async function runWithLangSmith(config: RunConfig): Promise<{
if (!entry) throw new Error(`No conversation found for fileSlug=${fileSlug}`);
try {
const start = Date.now();
const timeoutMs = effectiveTimeoutMs(entry.complexity, args.timeoutMs);
if (timeoutMs !== args.timeoutMs) {
logger.info(
` Complex case: per-iteration budget ${String(Math.round(timeoutMs / 1000))}s [${fileSlug}]`,
);
}
const build = await lane.tracedBuild({
conversation: entry.conversation,
messageBudget: entry.messageBudget,
@ -732,6 +742,7 @@ async function runWithLangSmith(config: RunConfig): Promise<{
seedFile: entry.seedFile,
priorConversation: entry.priorConversation,
seedThread: entry.seedThread,
timeoutMs,
});
const buildDurationMs = Date.now() - start;
buildDurations.set(key, buildDurationMs);
@ -877,6 +888,10 @@ async function runWithLangSmith(config: RunConfig): Promise<{
scenario,
workflowJsons: build.workflowJsons,
buildTrace: build.buildTrace,
timeoutMs: effectiveTimeoutMs(
testCaseByFileSlug.get(inputs.testCaseFile)?.complexity,
args.timeoutMs,
),
});
break;
} catch (error: unknown) {

View File

@ -11,13 +11,10 @@ import { execFileSync } from 'node:child_process';
import { basename } from 'node:path';
import { loadWorkflowTestCasesWithFiles } from '../data/workflows';
import { LangTracerClient, type LangTracerUpdateCaseBody } from '../langtracer/client';
import { LangTracerClient } from '../langtracer/client';
import { resolveLangTracerConfig } from '../langtracer/config';
import { planPush } from '../langtracer/push';
import {
diskCaseToLangTracerCreate,
type LangTracerCreateCaseBody,
} from '../langtracer/to-exported';
import { planPush, toUpdatePatch } from '../langtracer/push';
import { diskCaseToLangTracerCreate } from '../langtracer/to-exported';
interface CliArgs {
suite: string;
@ -154,16 +151,6 @@ function gitChangedSlugs(): string[] {
return slugs;
}
/** Drop the create-only fields `PATCH /cases/:id` rejects, leaving the patchable set. */
function toUpdatePatch({
suiteId,
synthetic,
scenarios,
...patch
}: LangTracerCreateCaseBody): LangTracerUpdateCaseBody {
return patch;
}
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (parsed.helpRequested) {

View File

@ -1,5 +1,5 @@
{
"description": "Vague monitoring request with an unspecified data source. The agent should ask where to get the flight status from before building; the user answers 'scrape FlightAware'. Outcome/scenario assertions are grounded in a real build run (schedule → scrape FlightAware → compare against stored last-seen status → alert only on change).",
"description": "Vague monitoring request with an unspecified data source. The agent should ask where to get the flight status from before building; the user answers 'scrape FlightAware' and pins storage to a Data Table (builds that keep last-seen state in $getWorkflowStaticData are unverifiable — eval executions start with empty static data, so the stateful scenarios can only be graded against a seedable store). Outcome/scenario assertions are grounded in a real build run (schedule → scrape FlightAware → compare against stored last-seen status → alert only on change).",
"conversation": [
{
"role": "user",
@ -11,7 +11,7 @@
},
{
"role": "user",
"text": "[Don't mention the data source unless the agent asks where to get the flight status from; then say 'Scrape it from FlightAware.']"
"text": "[Don't mention the data source unless the agent asks where to get the flight status from; then say 'Scrape it from FlightAware, and keep the last seen status in an n8n Data Table so each run can compare against it.']"
}
],
"messageBudget": 4,

View File

@ -10,7 +10,7 @@
},
{
"role": "user",
"text": "Pull OHLCV candles from the Binance klines REST API (GET https://api.binance.com/api/v3/klines) via an HTTP Request node, normalize them, compute indicators, and send the final signal to Telegram. Binance returns a JSON array of candle rows, so the normalize step has to handle every candle, not just the first one."
"text": "Pull OHLCV candles from the Binance klines REST API (GET https://api.binance.com/api/v3/klines) via an HTTP Request node, normalize them, compute indicators, and send the final signal to Telegram. Binance returns a JSON array of candle rows, so the normalize step has to handle every candle, not just the first one. One symbol is plenty — no need to track several — and a couple of momentum indicators are enough for the signal."
}
],
"complexity": "complex",

View File

@ -12,7 +12,7 @@
{
"role": "user",
"text": [
"Scrape the top trending topics from Hacker News, and read the 'Content Ideas' tab in my Marketing spreadsheet. Use Gemini to tailor the copy per platform — concise with hashtags for X, professional for LinkedIn, a discussion-style post for Reddit. Stagger them across the week: X on Monday 9am, LinkedIn on Wednesday 2pm, Reddit on Friday 11am.",
"Scrape the top trending topics from Hacker News — the top 5 titles are plenty — and read the 'Content Ideas' tab in my Marketing spreadsheet. Use Gemini to tailor the copy per platform — concise with hashtags for X, professional for LinkedIn, a discussion-style post for Reddit — one post per platform is enough. Stagger them across the week: X on Monday 9am, LinkedIn on Wednesday 2pm, Reddit on Friday 11am.",
"For every HITL step during the build — keep the run moving without stalling. Allow all permissions. Use any of the existing credentials or skip."
]
},

View File

@ -7,6 +7,7 @@
// ---------------------------------------------------------------------------
import type { InstanceAiConfirmRequest, InstanceAiEvalExecutionResult } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import crypto from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
@ -70,6 +71,7 @@ import type {
WorkflowTestCaseResult,
} from '../types';
import {
agentTurnsAsText,
conversationUserTurnsAsText,
failedBuildsPerTurn,
lastAgentText,
@ -92,6 +94,23 @@ import { UserProxyLlm, type ProxyDecisionStats } from '../utils/user-proxy';
const DEFAULT_TIMEOUT_MS = 900_000;
const EVAL_DATA_DIR = path.join(__dirname, '..', '..', '.data');
/**
* Per-case budget: `complex` cases get 1.5× the base timeout. The heaviest
* builds (multi-agent fan-outs, 5-integration pipelines) legitimately run at
* the shared default's cap — observed 777–900s with 4/10 builds timing out on
* weekly-social-content-scheduler in run 29012884140 — while the default must
* NOT rise globally (see the comment above: a generous default lets starved
* scenarios amplify the contention that starved them). Keyed off the authored
* `complexity` field so the budget travels with the case (incl. through the
* lang-tracer mirror) instead of a bespoke per-case knob.
*/
export function effectiveTimeoutMs(
complexity: WorkflowTestCase['complexity'] | undefined,
baseMs: number,
): number {
return complexity === 'complex' ? Math.round(baseMs * 1.5) : baseMs;
}
function getMaxConcurrentScenarios(): number {
const raw = process.env.N8N_EVAL_MAX_CONCURRENT_SCENARIOS;
const parsed = raw ? Number.parseInt(raw, 10) : 4;
@ -251,7 +270,11 @@ export async function runWorkflowTestCase(
config: WorkflowTestCaseConfig,
): Promise<WorkflowTestCaseResult> {
const { client, testCase, logger } = config;
const timeoutMs = config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
const baseTimeoutMs = config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
const timeoutMs = effectiveTimeoutMs(testCase.complexity, baseTimeoutMs);
if (timeoutMs !== baseTimeoutMs) {
logger.info(` Complex case: per-iteration budget ${String(Math.round(timeoutMs / 1000))}s`);
}
const result: WorkflowTestCaseResult = {
testCase,
@ -744,7 +767,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
{ ...eventOutcome, workflowIds: threadWorkflowIds },
config.preRunWorkflowIds,
config.claimedWorkflowIds,
{ allowListDiffFallback: config.allowWorkflowListDiffFallback === true },
{ allowListDiffFallback: config.allowWorkflowListDiffFallback === true, logger },
);
if (outcome.workflowsCreated.length === 0) {
@ -776,7 +799,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
: await runWorkflowChecks({
workflow: outcome.workflowJsons[0],
prompt: userTurnsAsText(transcript),
agentText: outcome.finalText,
agentText: agentTurnsAsText(transcript),
failedBuildsPerTurn: failedBuildsPerTurn(transcript),
logger,
});
@ -914,6 +937,21 @@ function scenarioMatchTokens(text: string): Set<string> {
);
}
/** Workflow ids referenced by enabled Execute Workflow nodes (database source,
* plain string or resource-locator value) — those targets are dependencies of
* this workflow, not entry points. */
function executeWorkflowReferences(wf: WorkflowResponse): string[] {
const ids: string[] = [];
for (const node of wf.nodes) {
if (node.disabled || node.type !== 'n8n-nodes-base.executeWorkflow') continue;
const params = node.parameters ?? {};
if (typeof params.source === 'string' && params.source !== 'database') continue;
const raw = isRecord(params.workflowId) ? params.workflowId.value : params.workflowId;
if (typeof raw === 'string' && raw.trim() !== '' && !raw.startsWith('=')) ids.push(raw.trim());
}
return ids;
}
/**
* Compositional builds split the system across multiple workflows (SKILL.md
* endorses this), but execution historically always ran `build.workflowId` —
@ -921,7 +959,11 @@ function scenarioMatchTokens(text: string): Set<string> {
* while the expectations judge (which sees every workflowJson) passed the
* same build. Mirror reality instead: a caller hits the specific endpoint, so
* route the scenario to the workflow whose trigger-bearing content best
* matches it. Single-workflow builds and ties keep the original id.
* matches it. Sub-workflows referenced by another candidate's Execute Workflow
* node are dependencies, not entry points — executing one directly starts it
* once with an empty payload, so they're demoted whenever an entry point
* remains. A single-candidate pool routes to that candidate: `workflowId`
* itself may be a sub-workflow (whichever the agent happened to save first).
*/
export function selectScenarioWorkflowId(
scenario: ExecutionScenario,
@ -933,15 +975,26 @@ export function selectScenarioWorkflowId(
(wf) =>
wf?.id && Array.isArray(wf.nodes) && wf.nodes.some((n) => isMockableTriggerNodeType(n.type)),
);
if (candidates.length <= 1) return workflowId;
if (candidates.length === 0) return workflowId;
const referencedIds = new Set(candidates.flatMap(executeWorkflowReferences));
const entryPoints = candidates.filter((wf) => !referencedIds.has(wf.id));
const pool = entryPoints.length > 0 ? entryPoints : candidates;
const fallbackId = pool.some((wf) => wf.id === workflowId) ? workflowId : pool[0].id;
const scenarioTokens = scenarioMatchTokens(`${scenario.name} ${scenario.dataSetup}`);
if (scenarioTokens.size === 0) return workflowId;
if (pool.length === 1 || scenarioTokens.size === 0) {
if (fallbackId !== workflowId) {
logger.info(
` [${scenario.name}] multi-workflow build: routing to entry point ${fallbackId}`,
);
}
return fallbackId;
}
let bestId = workflowId;
let bestId = fallbackId;
let bestScore = -1;
let tied = false;
for (const wf of candidates) {
for (const wf of pool) {
const haystackParts: string[] = [wf.name ?? ''];
for (const node of wf.nodes) {
haystackParts.push(String(node.name ?? ''));
@ -963,7 +1016,7 @@ export function selectScenarioWorkflowId(
}
}
if (tied || bestScore <= 0) return workflowId;
if (tied || bestScore <= 0) bestId = fallbackId;
if (bestId !== workflowId) {
logger.info(
` [${scenario.name}] multi-workflow build: routing to workflow ${bestId} (score ${String(bestScore)})`,

View File

@ -32,10 +32,12 @@ export type LangTracerSuiteSummary = z.infer<typeof suiteSummarySchema>;
export type ExportedSuite = z.infer<typeof exportedSuiteSchema>;
export type LangTracerCaseRef = z.infer<typeof caseRefSchema>;
/** Fields patchable via `PATCH /cases/:id` — a create body minus what that route
* rejects (`suiteId`/`synthetic` handled elsewhere, `scenarios` are sidecar rows). */
/** Fields patchable via `PATCH /cases/:id` — a create body minus the create-only
* keys (`suiteId`/`synthetic`). `scenarios` are sidecar rows the server reconciles
* by name on PATCH (upsert + delete missing); a server predating lang-tracer #48
* strips the key silently, leaving the old scenarios in place. */
export type LangTracerUpdateCaseBody = Partial<
Omit<LangTracerCreateCaseBody, 'suiteId' | 'synthetic' | 'scenarios'>
Omit<LangTracerCreateCaseBody, 'suiteId' | 'synthetic'>
>;
export class LangTracerClient {

View File

@ -3,8 +3,9 @@
// seeding). Pure — no network — so the create/update/unchanged partitioning is
// unit-testable against in-memory suite state.
import type { LangTracerUpdateCaseBody } from './client';
import { normalizeExportedCase } from './normalize';
import { unsupportedPushReason } from './to-exported';
import { unsupportedPushReason, type LangTracerCreateCaseBody } from './to-exported';
import type { WorkflowTestCaseWithFile } from '../data/workflows';
export interface PushPlan {
@ -14,9 +15,8 @@ export interface PushPlan {
skipped: Array<{ fileSlug: string; reason: string }>;
}
/** Disk fields compared to decide create-vs-update. Deliberately EXCLUDES three
/** Disk fields compared to decide create-vs-update. Deliberately EXCLUDES two
* fields that would make a re-push never converge (always "update"):
* - `executionScenarios`: sidecar rows `PATCH /cases/:id` can't update — written on create only.
* - `tags` and `datasets`: the lang-tracer suite export does not round-trip these
* (tags come back empty, default `datasets` comes back null/omitted), so a diff
* on them always fires. They're still SENT on create so new cases carry them;
@ -30,8 +30,24 @@ const COMPARED_KEYS = [
'outcomeExpectations',
'messageBudget',
'credentials',
// Round-trips faithfully: PATCH /cases/:id reconciles scenario rows by name
// (lang-tracer #48) and the export emits them back in disk shape.
'executionScenarios',
] as const;
/** Drop the create-only fields, leaving the patchable set (`scenarios` included —
* `PATCH /cases/:id` reconciles them by name since lang-tracer #48). An absent
* `scenarios` is sent as an explicit `[]`: a partial PATCH leaves missing keys
* untouched, so omitting it would keep the server's old scenario rows alive
* forever after a disk case drops its `executionScenarios`. */
export function toUpdatePatch({
suiteId,
synthetic,
...patch
}: LangTracerCreateCaseBody): LangTracerUpdateCaseBody {
return { scenarios: [], ...patch };
}
/** `existingBodies`: `<name>.json` → exported (disk-shape) body from `GET /suites/:id/export`.
* `existingIdsByName`: case name → id from `GET /suites/:id` (authoritative membership). */
export function planPush(

View File

@ -5,7 +5,7 @@
import type { InstanceAiAgentNode, InstanceAiMessage } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import type { N8nClient, WorkflowResponse } from '../clients/n8n-client';
import { N8nApiError, type N8nClient, type WorkflowResponse } from '../clients/n8n-client';
import type { AgentOutcome, EventOutcome, ExecutionSummary, WorkflowSummary } from '../types';
// ---------------------------------------------------------------------------
@ -38,6 +38,8 @@ export interface BuildAgentOutcomeOptions {
* run can create a workflow while the current thread produces no IDs.
*/
allowListDiffFallback?: boolean;
/** Logs discovery decisions (dropped phantom ids, deferred fetch-failure stubs). */
logger?: { warn(message: string): void };
}
export async function buildAgentOutcome(
@ -83,7 +85,13 @@ export async function buildAgentOutcome(
}
}
// Fetch workflow details
// Fetch workflow details. Candidate ids come from tool results and agent
// trees, which echo agent-INVENTED ids too (e.g. a failed build-workflow
// bind to a made-up id) — a 404/403 means no workflow ever carried the id,
// so drop it instead of recording a stub: a stub at index 0 becomes
// build.workflowId and every scenario then executes a nonexistent workflow.
// Transport-level fetch failures keep a stub, ordered after real workflows.
const fetchFailedStubs: WorkflowSummary[] = [];
for (const wfId of knownWfIds) {
try {
const wf = await client.getWorkflow(wfId);
@ -94,9 +102,14 @@ export async function buildAgentOutcome(
active: wf.active,
});
workflowJsons.push(wf);
} catch {
// Workflow may have been deleted or is inaccessible
workflowsCreated.push({
} catch (error) {
if (error instanceof N8nApiError && (error.status === 404 || error.status === 403)) {
options.logger?.warn(
` Discovery: dropping phantom workflow id "${wfId}" (fetch returned ${String(error.status)})`,
);
continue;
}
fetchFailedStubs.push({
id: wfId,
name: '(fetch failed)',
nodeCount: 0,
@ -104,6 +117,7 @@ export async function buildAgentOutcome(
});
}
}
workflowsCreated.push(...fetchFailedStubs);
// Fetch execution details (one listing for all ids)
if (eventOutcome.executionIds.length > 0) {

View File

@ -10,6 +10,7 @@ This is a test environment. No real credentials or API connections exist. ALL HT
IMPORTANT: Nodes receiving mock responses instead of real API responses is EXPECTED. Missing or mock credentials is EXPECTED. Don't flag these as issues — they are the testing mechanism itself.
IMPORTANT: When an AI root node such as an AI Agent is pinned, its connected AI subnodes (language model, memory, tools, retrievers, parsers) often do not run. This is expected. Evaluate those subnodes from the saved workflow structure, connections, and all-node configs instead of failing only because the subnode did not execute.
IMPORTANT: AI root output shapes differ by node type, and pinned outputs follow the REAL node behavior. An Agent root wraps its result in \`{ "output": ... }\`. A Basic LLM Chain (\`chainLlm\`) does NOT: with a structured output parser attached it emits the parsed fields FLAT at the top level of \`json\` (the parser unwraps any \`output\` envelope); without a parser it emits \`{ "text": "..." }\`. A downstream expression reading \`$json.output.*\` from a chainLlm root can never resolve against the real node either — that is a builder_issue (wrong expression for the node type), NOT a mock/pin issue. Do not claim the pin "should have had an output wrapper" for chainLlm.
Credential ID values in the workflow JSON (real, placeholder strings, or stale references) never cause execution failures. When a credential ID cannot be resolved, the framework substitutes a mock credential and execution proceeds. Do not cite credential ID values as a root cause of failure under any circumstance.
@ -71,10 +72,9 @@ Each branch's items are capped at 10 for artifact size. The full untruncated tot
## Failure categories
When a checklist item fails, categorize the root cause:
- **builder_issue**: The AI agent that built the workflow misconfigured a node (missing parameters, wrong settings, incomplete config, wrong routing logic, missing nodes). Evidence: configIssues flags, nodes crashing before making HTTP requests, Switch/IF nodes missing required options, workflow structure doesn't match what the prompt asked for. Also applies when the configured identifier could never resolve against any faithful API response (wrong-kind resource-locator value, e.g. "Sheet with ID Reservas not found" — see the lookup bullet in step 3). Also applies when a node's HTTP request was recorded WITHOUT a URL (an intercepted request showing "GET (no URL)" — "GET undefined" in older captures — or a 400 error stating "the node sent an HTTP request without a URL"): the node's routing produced an empty request because the selected resource/operation does not exist on that node type — the workflow could never work against the real API either. This is node misconfiguration, NOT a mock or framework problem. Also applies when a Code node receives correct input data but its connected downstream branch produces wrong output — that's wrong node logic. **For Filter / IF / Switch: an item appearing in the unmatched branch is NOT wrong output — it's correctly routed there by the predicate. Only flag a builder_issue when items that should have matched the predicate end up in the wrong branch.** When judging whether items matched, evaluate against the scenario's stated data values — if the mock's values contradict the scenario and that is why the predicate routed unexpectedly, it is a mock_issue, not a builder_issue. **Also applies when the builder produced an empty or trivial workflow (0 nodes, or only a trigger and no action nodes) — even if the build phase appears to have completed.** A "No trigger or start node found" execution error caused by zero nodes in the saved workflow is a builder failure, not a framework failure: the builder is responsible for committing at least a trigger.
- **builder_issue**: The AI agent that built the workflow misconfigured a node (missing parameters, wrong settings, incomplete config, wrong routing logic, missing nodes). Evidence: configIssues flags, nodes crashing before making HTTP requests, Switch/IF nodes missing required options, workflow structure doesn't match what the prompt asked for. Also applies when the configured identifier could never resolve against any faithful API response (wrong-kind resource-locator value, e.g. "Sheet with ID Reservas not found" — see the lookup bullet in step 3). Also applies when a node's HTTP request was recorded WITHOUT a URL (an intercepted request showing "GET (no URL)" — "GET undefined" in older captures — or a 400 error stating "the node sent an HTTP request without a URL"): the node's routing produced an empty request because the selected resource/operation does not exist on that node type — the workflow could never work against the real API either. This is node misconfiguration, NOT a mock or framework problem. Also applies when a Code node receives correct input data but its connected downstream branch produces wrong output — that's wrong node logic. **For Filter / IF / Switch: an item appearing in the unmatched branch is NOT wrong output — it's correctly routed there by the predicate. Only flag a builder_issue when items that should have matched the predicate end up in the wrong branch.** When judging whether items matched, evaluate against the scenario's stated data values — if the mock's values contradict the scenario and that is why the predicate routed unexpectedly, it is a mock_issue, not a builder_issue. **Also applies when the builder produced an empty or trivial workflow (0 nodes, or only a trigger and no action nodes) — even if the build phase appears to have completed.** A "No trigger or start node found" execution error caused by zero nodes in the saved workflow is a builder failure, not a framework failure: the builder is responsible for committing at least a trigger. Also applies when an Execute Workflow node crashes BEFORE invoking its sub-workflow because its own input mapping is invalid — \`workflowInputs.mappingMode: "defineBelow"\` with \`workflowInputs.value: null\` produces "Cannot convert undefined or null to object" at iteration 0. That saved config could never work against real n8n either. Do NOT rationalize this crash as the sub-workflow "not existing in the test environment" — the referenced sub-workflow was created by the same build; the crash happens in the parent's mapping code before any lookup.
- **mock_issue**: The LLM mock handler returned incorrect or missing data. Evidence: _evalMockError in responses, mock response shape doesn't match what the node expects, mock data missing fields that downstream nodes reference. IMPORTANT: Trace the data flow carefully — if the mock returned correct data but a downstream filter or code node transformed it incorrectly, that is a builder_issue, not a mock_issue. Also applies when the mock returned values that contradict the scenario's stated Data setup (wrong magnitude, percentage, or item count), causing a correctly-built gate, filter, or aggregation to produce an unexpected result.
- **legitimate_failure**: The workflow genuinely doesn't meet the success criteria and neither the builder nor mock is at fault. The test is working as designed — for example, the workflow lacks error handling that the scenario tests for.
- **framework_issue**: The evaluation framework itself failed delivering input to an otherwise-built workflow. Evidence: a built workflow with at least a trigger node exists, but Phase 1 returned an error or the trigger output is empty (empty JSON object), causing cascading failures. Pre-analysis flags starting with "FRAMEWORK ISSUE", "Phase 1 error" warnings. DOES NOT apply when the workflow is empty (0 nodes) — that is a builder_issue, see above.
- **framework_issue**: The evaluation framework itself failed delivering input to an otherwise-built workflow. Evidence: a built workflow with at least a trigger node exists, but Phase 1 returned an error or the trigger output is empty (empty JSON object), causing cascading failures. Pre-analysis flags starting with "FRAMEWORK ISSUE", "Phase 1 error" warnings. Also applies when the test environment cannot express the scenario's premise: the scenario presumes state left behind by earlier production runs (e.g. \`$getWorkflowStaticData\` holding a "previous" value — eval executions always start with empty static data), or the execution suspended at a node that models the passage of real time or an external callback the harness cannot deliver. Categorize these framework_issue consistently — not builder_issue — regardless of which scenario branch the empty state routed to. DOES NOT apply when the workflow is empty (0 nodes) — that is a builder_issue, see above. A workflow that runs as built but doesn't meet the success criteria is a builder_issue — the builder owns satisfying the scenario as written; there is no separate "legitimate failure" category.
- **verification_gap**: You don't have enough information in the artifact to make a determination.
NOT failure categories:

View File

@ -74,6 +74,21 @@ export function agentTextOf(turn: TranscriptTurn): string {
return turn.steps.flatMap((s) => (s.kind === 'agent-text' ? [s.text] : [])).join('');
}
/**
* Agent-side narration across a captured transcript, flattened as a text block
* for honesty checks. Single narrating turn → plain text; multi-turn → numbered
* by conversation turn so each claim aligns with the user turn that prompted it.
*/
export function agentTurnsAsText(transcript: TranscriptTurn[]): string {
const narrations = transcript
.map((turn, i) => ({ turn: i + 1, text: agentTextOf(turn) }))
.filter((n) => n.text.length > 0);
if (narrations.length === 0) return '';
if (narrations.length === 1) return narrations[0].text;
return narrations.map((n) => `Turn ${String(n.turn)}: ${n.text}`).join('\n\n');
}
/** The most recent turn's agent narration — a finalText fallback for seeded
* conversations whose live turn produced no text-delta events. */
export function lastAgentText(transcript: TranscriptTurn[]): string {

View File

@ -16,11 +16,14 @@ recommended_tools:
Use this skill when the user asks how to configure, set up, troubleshoot, or
understand n8n behavior and the answer should come from current n8n docs.
Before calling `n8n-docs`, load it via `load_tool` (search "n8n docs" if it is
not visible).
## Default Procedure
1. Call `n8n-docs(action="lookup")` first for credential setup and direct n8n
docs questions. Use `search` then `read` only when you need tighter control
over candidate pages.
1. Load `n8n-docs` via `load_tool` if needed, then call `n8n-docs(action="lookup")`
first for credential setup and direct n8n docs questions. Use `search` then
`read` only when you need tighter control over candidate pages.
2. For credential setup, pass `intent: "credential-setup"` and all available
context fields: `credentialType`, `credentialDisplayName`,
`documentationUrl`, `oauthRedirectUrl`, and `nodeType`.

Some files were not shown because too many files have changed in this diff Show More