diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 64ecddb54d4..8169493b6e6 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -336,6 +336,17 @@ export type FrontendModuleSettings = { autoExposeNewWorkflows: boolean; }; + /** + * Client settings for the instance-reporting module. Present only when the + * module is enabled on this instance. + */ + 'instance-reporting'?: { + /** Whether a receiver is configured, i.e. whether reports are actually sent. */ + enabled: boolean; + /** Minute of the UTC day the daily report fires at, as `HH:mm`. Absent when disabled. */ + reportTime?: string; + }; + /** * Client settings for the encryption-key-manager module. */ diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index d60658a066d..6a172dbee41 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -11,6 +11,7 @@ export type * from './community-node-types'; export type * from './quick-connect'; export * from './agents/index'; export * from './instance-registry-types'; +export type * from './instance-reporting'; export type * from './worker-pools'; export * from './redaction-enforcement'; export * from './redaction-enforcement-floor'; diff --git a/packages/@n8n/api-types/src/instance-reporting.ts b/packages/@n8n/api-types/src/instance-reporting.ts new file mode 100644 index 00000000000..98e9db707a9 --- /dev/null +++ b/packages/@n8n/api-types/src/instance-reporting.ts @@ -0,0 +1,7 @@ +export type InstanceReportingStatus = { + /** + * When the receiver last accepted a report, as an ISO 8601 UTC string, or + * `null` when it never did. + */ + lastSuccessfulReport: string | null; +}; diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index f3826c5b986..09304c7f98f 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -419,7 +419,7 @@ export default defineConfig( './src/license.ts', './src/modules/agents/integrations/n8n-checkpoint-storage.ts', './src/modules/instance-ai/instance-ai.service.ts', - './src/modules/instance-reporting/instance-reporting-scheduler.service.ts', + './src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts', './src/services/pruning/executions-pruning.service.ts', './src/services/pruning/workflow-history-compaction.service.ts', './src/services/workflow-statistics-rollup.service.ts', diff --git a/packages/cli/src/modules/instance-reporting/LOCAL_VERIFICATION.md b/packages/cli/src/modules/instance-reporting.ee/LOCAL_VERIFICATION.md similarity index 100% rename from packages/cli/src/modules/instance-reporting/LOCAL_VERIFICATION.md rename to packages/cli/src/modules/instance-reporting.ee/LOCAL_VERIFICATION.md diff --git a/packages/cli/src/modules/instance-reporting/README.md b/packages/cli/src/modules/instance-reporting.ee/README.md similarity index 81% rename from packages/cli/src/modules/instance-reporting/README.md rename to packages/cli/src/modules/instance-reporting.ee/README.md index 514e68e55d3..3450686a8ad 100644 --- a/packages/cli/src/modules/instance-reporting/README.md +++ b/packages/cli/src/modules/instance-reporting.ee/README.md @@ -85,7 +85,7 @@ there. | Env var | Default | Notes | |---|---|---| -| `N8N_INSTANCE_REPORTING_BASE_URL` | `''` | Base URL of the receiver. The report is POSTed to `/api/v1/instance-reports`. Left unset, the module loads but warns and never sends. | +| `N8N_INSTANCE_REPORTING_BASE_URL` | `''` | Base URL of the receiver. The report is POSTed to `/api/v1/instance-reports`. Left unset, the module loads but warns and never sends: it starts no scheduler and claims no report time. | | `N8N_INSTANCE_REPORTING_LABEL` | `''` | Sent as `label` in the payload, when set. | | `N8N_INSTANCE_REPORTING_AUTH_TOKEN` | `''` | Sent as `Authorization: Bearer …`, when set. | @@ -99,5 +99,38 @@ instance calling at the same minute. The time is always UTC and never before first; it shifts itself later, logging a warning, if `N8N_INSIGHTS_COMPACTION_INTERVAL_MINUTES` is raised enough to require it. +## Client settings + +`GET /rest/module-settings` carries an `instance-reporting` key when the module +is enabled on this instance: + +```json +{ + "instance-reporting": { + "enabled": true, + "reportTime": "07:42" + } +} +``` + +`enabled` says whether a receiver is configured. Without one the key reads +`{ "enabled": false }` and carries no `reportTime`, since no time is claimed. A +missing key means the module is not enabled at all. + +These settings are built once, during module init, and served from a cache +afterwards, so they carry only values that stay the same for the lifetime of +the process. + +## Reporting status + +`GET /rest/instance-reporting/status` answers with the UTC instant the receiver +last accepted a report, or `null` when it never did: + +```json +{ "lastSuccessfulReport": "2026-03-25T07:42:13.000Z" } +``` + +The route exists whenever the module is loaded, including without a receiver — the client decides whether to ask by reading `enabled` from the client settings. + See [.agents/specs/central-instance-monitoring.md](../../../../../.agents/specs/central-instance-monitoring.md) for the full design. diff --git a/packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-scheduler.service.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts similarity index 88% rename from packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-scheduler.service.test.ts rename to packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts index 579d184ca9f..2572890f56b 100644 --- a/packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-scheduler.service.test.ts +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts @@ -1,8 +1,6 @@ -import type { ModuleName, ModulesConfig } from '@n8n/backend-common'; import { mockLogger } from '@n8n/backend-test-utils'; import { Time } from '@n8n/constants'; import type { InstanceSettings } from 'n8n-core'; -import { UserError } from 'n8n-workflow'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -11,7 +9,6 @@ import type { EventService } from '@/events/event.service'; import type { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository'; import { InstanceReportingScheduler } from '../instance-reporting-scheduler.service'; import type { InstanceReportingSettingsService } from '../instance-reporting-settings.service'; -import { InstanceReportingConfig } from '../instance-reporting.config'; import type { InstanceReportingService } from '../instance-reporting.service'; const REPORT_TIME = '07:42'; @@ -28,24 +25,18 @@ interface Harness { reportRepository: Mocked; settingsService: Mocked; instanceSettings: Mocked; - modulesConfig: Mocked; eventService: Mocked; /** Fire the deferred `server-started` listeners (only needed when autoStart is false). */ startServer: () => void; } function makeHarness({ - baseUrl = 'https://example.com', isLeader = true, - disabledModules = [] as ModuleName[], // The scheduler arms its first tick on `server-started`. By default the harness // fires that listener the moment it is registered, so `init()` starts reporting // as the tests expect. Pass false to hold it and fire it later via `startServer`. autoStart = true, } = {}): Harness { - const config = new InstanceReportingConfig(); - config.instanceReportingBaseUrl = baseUrl; - const reportingService = mock(); // No attempt has been made yet, so nothing is holding the next one back. reportingService.msUntilRetryAllowed.mockResolvedValue(0); @@ -61,7 +52,6 @@ function makeHarness({ instanceRole: isLeader ? 'leader' : 'follower', isLeader, }); - const modulesConfig = mock({ disabledModules }); const serverStartedListeners: Array<() => void> = []; const eventService = mock(); @@ -81,12 +71,10 @@ function makeHarness({ }); const scheduler = new InstanceReportingScheduler( - config, reportingService, reportRepository, settingsService, instanceSettings, - modulesConfig, eventService, mockLogger(), ); @@ -97,7 +85,6 @@ function makeHarness({ reportRepository, settingsService, instanceSettings, - modulesConfig, eventService, startServer: () => serverStartedListeners.forEach((listener) => listener()), }; @@ -120,25 +107,10 @@ describe('InstanceReportingScheduler', () => { }); describe('init', () => { - test('fails when the insights module is disabled', async () => { - const { scheduler } = makeHarness({ disabledModules: ['insights'] }); - - await expect(scheduler.init()).rejects.toThrow(UserError); - }); - - test('warns and stays idle when no base URL is configured', async () => { - const { scheduler, reportingService } = makeHarness({ baseUrl: '' }); - - await scheduler.init(); - await vi.advanceTimersByTimeAsync(Time.days.toMilliseconds); - - expect(reportingService.sendReport).not.toHaveBeenCalled(); - }); - test('does not start a timer on a follower', async () => { const { scheduler, reportingService } = makeHarness({ isLeader: false }); - await scheduler.init(); + scheduler.init(); await vi.advanceTimersByTimeAsync(Time.days.toMilliseconds); expect(reportingService.sendReport).not.toHaveBeenCalled(); @@ -147,7 +119,7 @@ describe('InstanceReportingScheduler', () => { test('fires at the configured time on the leader', async () => { const { scheduler, reportingService } = makeHarness(); - await scheduler.init(); + scheduler.init(); await settle(); expect(reportingService.sendReport).not.toHaveBeenCalled(); @@ -160,7 +132,7 @@ describe('InstanceReportingScheduler', () => { test('fires once a day thereafter', async () => { const { scheduler, reportingService } = makeHarness(); - await scheduler.init(); + scheduler.init(); await vi.advanceTimersByTimeAsync(3 * Time.days.toMilliseconds); expect(reportingService.sendReport).toHaveBeenCalledTimes(3); @@ -172,7 +144,7 @@ describe('InstanceReportingScheduler', () => { vi.setSystemTime(new Date(AFTER_SLOT)); const { scheduler, reportingService, startServer } = makeHarness({ autoStart: false }); - await scheduler.init(); + scheduler.init(); await settle(); expect(reportingService.sendReport).not.toHaveBeenCalled(); @@ -188,7 +160,7 @@ describe('InstanceReportingScheduler', () => { vi.setSystemTime(new Date(AFTER_SLOT)); const { scheduler, reportingService } = makeHarness(); - await scheduler.init(); + scheduler.init(); await settle(); expect(reportingService.sendReport).toHaveBeenCalledTimes(1); @@ -199,7 +171,7 @@ describe('InstanceReportingScheduler', () => { const { scheduler, reportingService, reportRepository } = makeHarness(); reportRepository.hasSettledToday.mockResolvedValue(true); - await scheduler.init(); + scheduler.init(); await settle(); expect(reportingService.sendReport).not.toHaveBeenCalled(); @@ -209,7 +181,7 @@ describe('InstanceReportingScheduler', () => { vi.setSystemTime(new Date(AFTER_SLOT)); const { scheduler, reportingService, reportRepository } = makeHarness(); - await scheduler.init(); + scheduler.init(); await settle(); // The catch-up delivered it, so the rest of the day has nothing to do. reportRepository.hasSettledToday.mockResolvedValue(true); @@ -224,7 +196,7 @@ describe('InstanceReportingScheduler', () => { vi.setSystemTime(new Date(AFTER_SLOT)); const { scheduler, reportingService, instanceSettings } = makeHarness({ isLeader: false }); - await scheduler.init(); + scheduler.init(); await settle(); expect(reportingService.sendReport).not.toHaveBeenCalled(); @@ -246,7 +218,7 @@ describe('InstanceReportingScheduler', () => { vi.setSystemTime(new Date(AFTER_SLOT)); const { scheduler, reportingService, startServer } = makeHarness({ autoStart: false }); - await scheduler.init(); + scheduler.init(); scheduler.stop(); startServer(); await settle(); @@ -257,7 +229,7 @@ describe('InstanceReportingScheduler', () => { test('stops reporting when this main steps down', async () => { const { scheduler, reportingService } = makeHarness(); - await scheduler.init(); + scheduler.init(); await settle(); scheduler.stop(); await vi.advanceTimersByTimeAsync(2 * Time.days.toMilliseconds); @@ -268,7 +240,7 @@ describe('InstanceReportingScheduler', () => { test('shutdown stops the timer and blocks a later takeover', async () => { const { scheduler, reportingService } = makeHarness(); - await scheduler.init(); + scheduler.init(); await settle(); scheduler.shutdown(); scheduler.start(); @@ -284,7 +256,7 @@ describe('InstanceReportingScheduler', () => { const { scheduler, reportingService } = makeHarness(); reportingService.sendReport.mockRejectedValueOnce(new Error('Network error')); - await scheduler.init(); + scheduler.init(); await settle(); expect(reportingService.sendReport).toHaveBeenCalledTimes(1); @@ -303,7 +275,7 @@ describe('InstanceReportingScheduler', () => { async () => reportingService.sendReport.mock.calls.length >= 3, ); - await scheduler.init(); + scheduler.init(); await vi.advanceTimersByTimeAsync(Time.hours.toMilliseconds); expect(reportingService.sendReport).toHaveBeenCalledTimes(3); @@ -316,7 +288,7 @@ describe('InstanceReportingScheduler', () => { const { scheduler, reportingService } = makeHarness(); reportingService.msUntilRetryAllowed.mockResolvedValueOnce(2 * Time.minutes.toMilliseconds); - await scheduler.init(); + scheduler.init(); await settle(); expect(reportingService.sendReport).not.toHaveBeenCalled(); @@ -329,7 +301,7 @@ describe('InstanceReportingScheduler', () => { const { scheduler, settingsService } = makeHarness(); settingsService.getReportTime.mockRejectedValueOnce(new Error('DB down')); - await scheduler.init(); + scheduler.init(); await settle(); expect(settingsService.getReportTime).toHaveBeenCalledTimes(1); @@ -343,7 +315,7 @@ describe('InstanceReportingScheduler', () => { test('a fire before the slot reports nothing and re-arms', async () => { const { scheduler, reportingService } = makeHarness(); - await scheduler.init(); + scheduler.init(); await settle(); // Clock jumps backwards an hour, well before the slot. vi.setSystemTime(new Date('2026-03-26T06:41:00.000Z')); diff --git a/packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-settings.service.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-settings.service.test.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-settings.service.test.ts rename to packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-settings.service.test.ts diff --git a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.controller.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.controller.test.ts new file mode 100644 index 00000000000..6f4e873962c --- /dev/null +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.controller.test.ts @@ -0,0 +1,28 @@ +import { mock } from 'vitest-mock-extended'; + +import type { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository'; +import { InstanceReportingController } from '../instance-reporting.controller'; + +const LAST_DELIVERY = new Date('2026-03-25T07:42:13.000Z'); + +describe('InstanceReportingController', () => { + describe('getStatus()', () => { + it('reports when the receiver last accepted a report', async () => { + const reportRepository = mock(); + reportRepository.findLastDeliveryTime.mockResolvedValue(LAST_DELIVERY); + + const status = await new InstanceReportingController(reportRepository).getStatus(); + + expect(status).toEqual({ lastSuccessfulReport: LAST_DELIVERY.toISOString() }); + }); + + it('reports a null last delivery until the receiver accepts a report', async () => { + const reportRepository = mock(); + reportRepository.findLastDeliveryTime.mockResolvedValue(null); + + const status = await new InstanceReportingController(reportRepository).getStatus(); + + expect(status).toEqual({ lastSuccessfulReport: null }); + }); + }); +}); diff --git a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.module.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.module.test.ts new file mode 100644 index 00000000000..5a7e271b536 --- /dev/null +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.module.test.ts @@ -0,0 +1,115 @@ +import type { FrontendModuleSettings } from '@n8n/api-types'; +import type { ModuleName } from '@n8n/backend-common'; +import { Logger, ModulesConfig } from '@n8n/backend-common'; +import { mockLogger } from '@n8n/backend-test-utils'; +import type { Controller } from '@n8n/decorators'; +import { ControllerRegistryMetadata, ModuleMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository'; +import { InstanceReportingScheduler } from '../instance-reporting-scheduler.service'; +import { InstanceReportingSettingsService } from '../instance-reporting-settings.service'; +import { InstanceReportingConfig } from '../instance-reporting.config'; +import { InstanceReportingModule } from '../instance-reporting.module'; + +// Compile-time pin: the settings this module returns are read by the frontend +// under this exact key, so the decorator name must stay a member of +// `FrontendModuleSettings`. +const MODULE_NAME = 'instance-reporting' satisfies keyof FrontendModuleSettings; + +const REPORT_TIME = '07:42'; +const LAST_DELIVERY = new Date('2026-03-25T07:42:13.000Z'); + +function setUpContainer({ + baseUrl = 'https://example.com', + disabledModules = [] as ModuleName[], + lastDelivery = LAST_DELIVERY as Date | null, +} = {}) { + const config = new InstanceReportingConfig(); + config.instanceReportingBaseUrl = baseUrl; + Container.set(InstanceReportingConfig, config); + + Container.set(ModulesConfig, mock({ disabledModules })); + Container.set(Logger, mockLogger()); + + const settingsService = mock(); + settingsService.getReportTime.mockResolvedValue(REPORT_TIME); + Container.set(InstanceReportingSettingsService, settingsService); + + const reportRepository = mock(); + reportRepository.findLastDeliveryTime.mockResolvedValue(lastDelivery); + Container.set(InstanceMonitoringReportRepository, reportRepository); + + const scheduler = mock(); + Container.set(InstanceReportingScheduler, scheduler); + + return { settingsService, reportRepository, scheduler }; +} + +describe('InstanceReportingModule', () => { + describe('settings()', () => { + it('reports the time the instance is due to report at', async () => { + setUpContainer(); + + const settings = await new InstanceReportingModule().settings(); + + expect(settings).toEqual({ enabled: true, reportTime: REPORT_TIME }); + }); + + it('reports as disabled, claiming no time and reading nothing, without a receiver', async () => { + const { settingsService, reportRepository } = setUpContainer({ baseUrl: '' }); + + const settings = await new InstanceReportingModule().settings(); + + expect(settings).toEqual({ enabled: false }); + expect(settingsService.getReportTime).not.toHaveBeenCalled(); + expect(reportRepository.findLastDeliveryTime).not.toHaveBeenCalled(); + }); + }); + + describe('init()', () => { + it('starts the scheduler', async () => { + const { scheduler } = setUpContainer(); + + await new InstanceReportingModule().init(); + + expect(scheduler.init).toHaveBeenCalled(); + }); + + it('fails when the insights module is disabled', async () => { + setUpContainer({ disabledModules: ['insights'] }); + + await expect(new InstanceReportingModule().init()).rejects.toThrow(UserError); + }); + + it('leaves the scheduler alone when no receiver is configured', async () => { + const { scheduler } = setUpContainer({ baseUrl: '' }); + + await new InstanceReportingModule().init(); + + expect(scheduler.init).not.toHaveBeenCalled(); + }); + + // The route belongs to the loaded module, not to the receiver, so a client + // that asks anyway gets an answer instead of a 404. + it('registers the status route even when no receiver is configured', async () => { + setUpContainer({ baseUrl: '' }); + + await new InstanceReportingModule().init(); + + const { InstanceReportingController } = await import('../instance-reporting.controller.js'); + const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata( + InstanceReportingController as Controller, + ); + + expect(metadata.basePath).toBe('/instance-reporting'); + expect(metadata.routes.get('getStatus')?.path).toBe('/status'); + }); + }); + + it('is registered under the module-settings key the frontend reads', () => { + expect(Container.get(ModuleMetadata).get(MODULE_NAME)?.class).toBe(InstanceReportingModule); + }); +}); diff --git a/packages/cli/src/modules/instance-reporting/__tests__/instance-reporting.service.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.service.test.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/__tests__/instance-reporting.service.test.ts rename to packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.service.test.ts diff --git a/packages/cli/src/modules/instance-reporting/database/entities/instance-monitoring-report.ts b/packages/cli/src/modules/instance-reporting.ee/database/entities/instance-monitoring-report.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/database/entities/instance-monitoring-report.ts rename to packages/cli/src/modules/instance-reporting.ee/database/entities/instance-monitoring-report.ts diff --git a/packages/cli/src/modules/instance-reporting/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts b/packages/cli/src/modules/instance-reporting.ee/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts similarity index 83% rename from packages/cli/src/modules/instance-reporting/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts rename to packages/cli/src/modules/instance-reporting.ee/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts index 907c0ef67cf..e60a5fb55a2 100644 --- a/packages/cli/src/modules/instance-reporting/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts +++ b/packages/cli/src/modules/instance-reporting.ee/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts @@ -166,6 +166,43 @@ describe('InstanceMonitoringReportRepository', () => { }); }); + describe('findLastDeliveryTime', () => { + test('is null when nothing was ever delivered', async () => { + await repository.createPending(DATA_POINTS); + + await expect(repository.findLastDeliveryTime()).resolves.toBeNull(); + }); + + test('returns the latest delivery time across delivered reports', async () => { + const earlier = await repository.createPending(DATA_POINTS); + await repository.markDelivered(earlier.id, new Date('2026-03-24T07:42:00.000Z')); + + const latest = await repository.createPending(DATA_POINTS); + const latestDeliveredAt = new Date('2026-03-25T07:42:13.000Z'); + await repository.markDelivered(latest.id, latestDeliveredAt); + + const lastDelivery = await repository.findLastDeliveryTime(); + + expect(lastDelivery?.toISOString()).toBe(latestDeliveredAt.toISOString()); + }); + + test('ignores reports that never reached the receiver', async () => { + const delivered = await repository.createPending(DATA_POINTS); + const deliveredAt = new Date('2026-03-24T07:42:00.000Z'); + await repository.markDelivered(delivered.id, deliveredAt); + + const failed = await repository.createPending(DATA_POINTS); + await repository.recordFailure(failed.id, 'Network error', new Date()); + const skipped = await repository.createPending(DATA_POINTS); + await repository.markSkipped(skipped.id); + await repository.createPending(DATA_POINTS); + + const lastDelivery = await repository.findLastDeliveryTime(); + + expect(lastDelivery?.toISOString()).toBe(deliveredAt.toISOString()); + }); + }); + describe('markDelivered', () => { test('stamps the delivery time, counts the attempt and clears any earlier error', async () => { const { id } = await repository.createPending(DATA_POINTS); diff --git a/packages/cli/src/modules/instance-reporting/database/repositories/instance-monitoring-report.repository.ts b/packages/cli/src/modules/instance-reporting.ee/database/repositories/instance-monitoring-report.repository.ts similarity index 91% rename from packages/cli/src/modules/instance-reporting/database/repositories/instance-monitoring-report.repository.ts rename to packages/cli/src/modules/instance-reporting.ee/database/repositories/instance-monitoring-report.repository.ts index 096bcccaebe..3bf6ee8fb21 100644 --- a/packages/cli/src/modules/instance-reporting/database/repositories/instance-monitoring-report.repository.ts +++ b/packages/cli/src/modules/instance-reporting.ee/database/repositories/instance-monitoring-report.repository.ts @@ -1,5 +1,5 @@ import { Service } from '@n8n/di'; -import { DataSource, MoreThanOrEqual, Not, Repository } from '@n8n/typeorm'; +import { DataSource, IsNull, MoreThanOrEqual, Not, Repository } from '@n8n/typeorm'; import { v4 as uuid } from 'uuid'; import type { InstanceReportDataPoint } from '../entities/instance-monitoring-report'; @@ -85,6 +85,19 @@ export class InstanceMonitoringReportRepository extends Repository (a > b ? a : b)) : null; } + /** + * When the receiver last accepted a report, or `null` when it never did. + */ + async findLastDeliveryTime(): Promise { + const report = await this.findOne({ + where: { status: 'delivered', deliveredAt: Not(IsNull()) }, + order: { deliveredAt: 'DESC' }, + select: ['deliveredAt'], + }); + + return report?.deliveredAt ?? null; + } + async markDelivered(id: string, deliveredAt: Date): Promise { await this.increment({ id }, 'attempts', 1); await this.update( diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting-scheduler.service.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts similarity index 84% rename from packages/cli/src/modules/instance-reporting/instance-reporting-scheduler.service.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts index 53f0e4c5752..3950a08795d 100644 --- a/packages/cli/src/modules/instance-reporting/instance-reporting-scheduler.service.ts +++ b/packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts @@ -1,18 +1,16 @@ -import { Logger, ModulesConfig } from '@n8n/backend-common'; +import { Logger } from '@n8n/backend-common'; import { Time } from '@n8n/constants'; import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import { UserError } from 'n8n-workflow'; import { strict } from 'node:assert'; +import { EventService } from '@/events/event.service'; + import { InstanceMonitoringReportRepository } from './database/repositories/instance-monitoring-report.repository'; import { InstanceReportingSettingsService } from './instance-reporting-settings.service'; -import { InstanceReportingConfig } from './instance-reporting.config'; import { InstanceReportingService, RETRY_DELAY_MS } from './instance-reporting.service'; -import { EventService } from '@/events/event.service'; - const MINUTES_PER_DAY = 24 * 60; /** @@ -43,12 +41,10 @@ export class InstanceReportingScheduler { private readonly onServerStarted = () => this.start(); constructor( - private readonly config: InstanceReportingConfig, private readonly reportingService: InstanceReportingService, private readonly reportRepository: InstanceMonitoringReportRepository, private readonly settingsService: InstanceReportingSettingsService, private readonly instanceSettings: InstanceSettings, - private readonly modulesConfig: ModulesConfig, private readonly eventService: EventService, private readonly logger: Logger, ) { @@ -56,29 +52,16 @@ export class InstanceReportingScheduler { } /** - * Validate what the module needs, then start reporting if this instance leads. + * Start reporting if this instance leads. * - * Runs from the module entrypoint, which the registry calls after + * Runs from the module entrypoint, which checks that the module has what it + * needs before it loads this class at all, and which the registry calls after * `initOrchestration` has settled this instance's role, so `isLeader` is * already meaningful here. */ - async init(): Promise { + init(): void { strict(this.instanceSettings.instanceRole !== 'unset', 'Instance role is not set'); - // The daily figure is read from insights, so the reporter cannot run without it. - if (this.modulesConfig.disabledModules.includes('insights')) { - throw new UserError( - 'The `instance-reporting` module requires the `insights` module, but it is listed in N8N_DISABLED_MODULES. Remove `insights` from N8N_DISABLED_MODULES or remove `instance-reporting` from N8N_ENABLED_MODULES.', - ); - } - - if (!this.config.instanceReportingBaseUrl) { - this.logger.warn( - 'Instance reporting is enabled but N8N_INSTANCE_REPORTING_BASE_URL is unset, so no reports will be sent', - ); - return; - } - // Defer the first tick until the server has finished starting. A boot // catch-up report can send right away, but log streaming module was not properly initialized if (this.instanceSettings.isLeader) { @@ -87,11 +70,7 @@ export class InstanceReportingScheduler { } get isEnabled(): boolean { - return ( - this.instanceSettings.instanceType === 'main' && - this.instanceSettings.isLeader && - this.config.instanceReportingBaseUrl !== '' - ); + return this.instanceSettings.instanceType === 'main' && this.instanceSettings.isLeader; } /** diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting-settings.service.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting-settings.service.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/instance-reporting-settings.service.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting-settings.service.ts diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting.config.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.config.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/instance-reporting.config.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting.config.ts diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting.constants.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.constants.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/instance-reporting.constants.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting.constants.ts diff --git a/packages/cli/src/modules/instance-reporting.ee/instance-reporting.controller.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.controller.ts new file mode 100644 index 00000000000..fc72daed9d3 --- /dev/null +++ b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.controller.ts @@ -0,0 +1,16 @@ +import type { InstanceReportingStatus } from '@n8n/api-types'; +import { Get, RestController } from '@n8n/decorators'; + +import { InstanceMonitoringReportRepository } from './database/repositories/instance-monitoring-report.repository'; + +@RestController('/instance-reporting') +export class InstanceReportingController { + constructor(private readonly reportRepository: InstanceMonitoringReportRepository) {} + + @Get('/status') + async getStatus(): Promise { + const lastDelivery = await this.reportRepository.findLastDeliveryTime(); + + return { lastSuccessfulReport: lastDelivery?.toISOString() ?? null }; + } +} diff --git a/packages/cli/src/modules/instance-reporting.ee/instance-reporting.module.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.module.ts new file mode 100644 index 00000000000..fe73d03a305 --- /dev/null +++ b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.module.ts @@ -0,0 +1,84 @@ +import { Logger, ModulesConfig } from '@n8n/backend-common'; +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { UserError } from 'n8n-workflow'; + +/** + * Reports this instance's billable execution numbers to a central monitoring + * receiver, once a day. + * + * Opt-in: not a default module, so it runs only when an operator lists it in + * `N8N_ENABLED_MODULES`. Main-only, and within a multi-main deployment only the + * leader holds the timer, so a cluster reports once rather than once per main. + * + * The daily figure comes from the insights module, but the receiver only sees + * data points, so that source is an implementation detail of + * `InstanceReportingService` rather than part of the reporting contract. + */ +@BackendModule({ name: 'instance-reporting', instanceTypes: ['main'] }) +export class InstanceReportingModule implements ModuleInterface { + async init() { + // Imported before the receiver check, so the route exists whenever the + // module is loaded. The client gates off `enabled` in the module settings. + await import('./instance-reporting.controller.js'); + + // The daily figure is read from insights, so the reporter cannot run without it. + if (Container.get(ModulesConfig).disabledModules.includes('insights')) { + throw new UserError( + 'The `instance-reporting` module requires the `insights` module, but it is listed in N8N_DISABLED_MODULES. Remove `insights` from N8N_DISABLED_MODULES or remove `instance-reporting` from N8N_ENABLED_MODULES.', + ); + } + + if (!(await this.isConfigured())) { + Container.get(Logger) + .scoped('instance-reporting') + .warn( + 'Instance reporting is enabled but N8N_INSTANCE_REPORTING_BASE_URL is unset, so no reports will be sent', + ); + return; + } + + const { InstanceReportingScheduler } = await import( + './instance-reporting-scheduler.service.js' + ); + + Container.get(InstanceReportingScheduler).init(); + } + + /** + * Settings exposed to the frontend under `/rest/module-settings`. + * + * Return values: + * { enabled: false } - module loaded but no receiver configured + * { enabled: true, reportTime: 'HH:mm' } - module loaded and receiver configured + * + * Built once at startup and cached for the process lifetime. + **/ + async settings() { + if (!(await this.isConfigured())) return { enabled: false }; + + const { InstanceReportingSettingsService } = await import( + './instance-reporting-settings.service.js' + ); + + const reportTime = await Container.get(InstanceReportingSettingsService).getReportTime(); + + return { enabled: true, reportTime }; + } + + async entities() { + const { InstanceMonitoringReport } = await import( + './database/entities/instance-monitoring-report.js' + ); + + return [InstanceMonitoringReport]; + } + + /** Whether a receiver is configured, i.e. whether reports are actually sent. */ + private async isConfigured(): Promise { + const { InstanceReportingConfig } = await import('./instance-reporting.config.js'); + + return Container.get(InstanceReportingConfig).instanceReportingBaseUrl !== ''; + } +} diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting.service.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.service.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/instance-reporting.service.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting.service.ts diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting.module.ts b/packages/cli/src/modules/instance-reporting/instance-reporting.module.ts deleted file mode 100644 index 67a65484ff9..00000000000 --- a/packages/cli/src/modules/instance-reporting/instance-reporting.module.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ModuleInterface } from '@n8n/decorators'; -import { BackendModule } from '@n8n/decorators'; -import { Container } from '@n8n/di'; - -/** - * Reports this instance's billable execution numbers to a central monitoring - * receiver, once a day. - * - * Opt-in: not a default module, so it runs only when an operator lists it in - * `N8N_ENABLED_MODULES`. Main-only, and within a multi-main deployment only the - * leader holds the timer, so a cluster reports once rather than once per main. - * - * The daily figure comes from the insights module, but the receiver only sees - * data points, so that source is an implementation detail of - * `InstanceReportingService` rather than part of the reporting contract. - */ -@BackendModule({ name: 'instance-reporting', instanceTypes: ['main'] }) -export class InstanceReportingModule implements ModuleInterface { - async init() { - const { InstanceReportingScheduler } = await import( - './instance-reporting-scheduler.service.js' - ); - - await Container.get(InstanceReportingScheduler).init(); - } - - async entities() { - const { InstanceMonitoringReport } = await import( - './database/entities/instance-monitoring-report.js' - ); - - return [InstanceMonitoringReport]; - } -} diff --git a/packages/frontend/@n8n/rest-api-client/src/api/index.ts b/packages/frontend/@n8n/rest-api-client/src/api/index.ts index 8dc81622d82..e2e1a160b73 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/index.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/index.ts @@ -6,6 +6,7 @@ export * from './credentialResolvers'; export * from './ctas'; export * from './eventbus.ee'; export * from './instance-registry'; +export * from './instance-reporting'; export * from './instance-version-history'; export * from './events'; export * from './externalSecrets.ee'; diff --git a/packages/frontend/@n8n/rest-api-client/src/api/instance-reporting.ts b/packages/frontend/@n8n/rest-api-client/src/api/instance-reporting.ts new file mode 100644 index 00000000000..cc498144625 --- /dev/null +++ b/packages/frontend/@n8n/rest-api-client/src/api/instance-reporting.ts @@ -0,0 +1,10 @@ +import type { InstanceReportingStatus } from '@n8n/api-types'; + +import type { IRestApiContext } from '../types'; +import { makeRestApiRequest } from '../utils'; + +export async function getInstanceReportingStatus( + context: IRestApiContext, +): Promise { + return await makeRestApiRequest(context, 'GET', '/instance-reporting/status'); +} diff --git a/scripts/lint-parity/samples.json b/scripts/lint-parity/samples.json new file mode 100644 index 00000000000..004b8c9d40f --- /dev/null +++ b/scripts/lint-parity/samples.json @@ -0,0 +1,398 @@ +{ + "packages/@n8n/agents": [ + "src/__tests__/agent-configuration.test.ts", + "src/__tests__/integration/agent-vector-store-pinecone.test.ts", + "src/evals/categorization.ts" + ], + "packages/@n8n/ai-node-sdk": ["src/index.ts"], + "packages/@n8n/ai-utilities": [ + "integration-tests/openai.test.ts", + "src/__tests__/adapters/langchain-chat-model.test.ts", + "src/adapters/langchain-chat-model.ts" + ], + "packages/@n8n/ai-workflow-builder.ee": [ + "evaluations/__tests__/argument-parser.test.ts", + "src/agents/planner.agent.ts", + "src/test/ai-workflow-builder-agent.service.test.ts" + ], + "packages/@n8n/api-types": [ + "src/__tests__/agent-builder-interactive.test.ts", + "src/agent-builder-interactive.ts" + ], + "packages/@n8n/backend-common": ["src/__tests__/cli-parser.test.ts", "src/cli-parser.ts"], + "packages/@n8n/backend-network": [ + "src/dns/__tests__/dns-resolver.test.ts", + "src/dns/dns-resolver.ts" + ], + "packages/@n8n/backend-test-utils": ["src/db/projects.ts"], + "packages/@n8n/benchmark": ["src/commands/list.ts"], + "packages/@n8n/blob-storage": [ + "src/__tests__/azure-byte-store.test.ts", + "src/azure-blob/azure-blob.config.ts" + ], + "packages/@n8n/chat-hub": ["src/artifact.test.ts", "src/artifact.ts"], + "packages/@n8n/cli": [ + "src/__tests__/client.test.ts", + "src/base-command.ts", + "src/client.ts", + "src/commands/login.ts", + "src/index.ts" + ], + "packages/@n8n/client-oauth2": ["src/client-oauth2-token.ts", "test/client-oauth2.test.ts"], + "packages/@n8n/codemirror-lang-sql": ["src/complete.ts", "test/complete.test.ts"], + "packages/@n8n/codemirror-lang": [ + "src/expressions/grammar.terms.ts", + "test/expressions/expressions.test.ts" + ], + "packages/@n8n/computer-use": [ + "src/__mocks__/@inquirer/prompts.ts", + "src/config-templates.test.ts" + ], + "packages/@n8n/config": [ + "src/configs/__tests__/ai.config.test.ts", + "src/configs/activity-log.config.ts" + ], + "packages/@n8n/constants": ["src/api.ts"], + "packages/@n8n/crdt": [ + "src/__tests__/helpers.ts", + "src/awareness/awareness.test.ts", + "src/awareness/index.ts" + ], + "packages/@n8n/db": [ + "src/connection/__tests__/backoff.test.ts", + "src/connection/backoff.ts", + "src/migrations/__tests__/migration-helpers.ts" + ], + "packages/@n8n/decorators": [ + "src/__tests__/memoized.test.ts", + "src/auth-handler/auth-handler-metadata.ts" + ], + "packages/@n8n/di": ["src/__tests__/circular-depedency.test.ts", "src/di.ts"], + "packages/@n8n/engine": [ + "src/admittance/__tests__/allow-all-admittance.test.ts", + "src/admittance/admittance-rejected.error.ts" + ], + "packages/@n8n/errors": ["src/application.error.ts"], + "packages/@n8n/eslint-plugin-community-nodes": [ + "src/plugin.ts", + "src/rules/ai-node-package-json.test.ts" + ], + "packages/@n8n/extension-sdk": ["src/backend/define.ts"], + "packages/@n8n/imap": ["src/connection-options.test.ts", "src/connection-options.ts"], + "packages/@n8n/instance-ai": [ + "evaluations/__tests__/agent-report-rendering.test.ts", + "evaluations/computer-use/report-html.ts", + "src/__tests__/binary-checks.test.ts", + "src/agent/aia-model-defaults.ts", + "src/tools/__tests__/activity.tool.test.ts" + ], + "packages/@n8n/json-schema-to-zod": ["src/index.ts", "test/json-schema-to-zod.test.ts"], + "packages/@n8n/local-gateway": ["src/main/connect-origin.test.ts", "src/main/connect-origin.ts"], + "packages/@n8n/mcp-apps": [ + "src/apps-manifest.ts", + "src/apps/workflow-preview/App.vue", + "src/apps/workflow-preview/composables/use-workflow-preview.test.ts" + ], + "packages/@n8n/mcp-browser-extension": [ + "src/approvedHosts.test.ts", + "src/approvedHosts.ts", + "src/ui/App.vue" + ], + "packages/@n8n/mcp-browser": [ + "src/__tests__/cdp-relay-attach.test.ts", + "src/adapters/agent-browser.ts" + ], + "packages/@n8n/node-cli": [ + "src/commands/build.test.ts", + "src/commands/build.ts", + "src/configs/eslint.ts", + "src/modules.d.ts", + "src/test-utils/command-tester.ts" + ], + "packages/@n8n/node-engine-compatibility": [ + "src/__tests__/acceptance-fixtures.ts", + "src/__tests__/engine-step-data-loader.test.ts", + "src/constants.ts" + ], + "packages/@n8n/nodes-langchain": [ + "credentials/AlibabaCloudApi.credentials.ts", + "credentials/test/AnthropicApi.credentials.test.ts", + "nodes/Guardrails/Guardrails.node.ts", + "nodes/Guardrails/helpers/__tests__/model.test.ts", + "nodes/mcp/McpClient/__test__/McpClient.node.test.ts" + ], + "packages/@n8n/permissions": ["src/__tests__/schemas.test.ts", "src/constants.ee.ts"], + "packages/@n8n/scheduler": ["src/__tests__/dependency-purity.test.ts", "src/core/clock-skew.ts"], + "packages/@n8n/syslog-client": ["src/client.ts", "test/client.test.ts", "vite.config.ts"], + "packages/@n8n/task-runner": [ + "src/__tests__/node-types.test.ts", + "src/config/base-runner-config.ts" + ], + "packages/@n8n/telemetry": ["src/__tests__/catalog.test.ts", "src/catalog.ts"], + "packages/@n8n/tournament": ["src/Constants.ts", "test/ASTHooks.test.ts"], + "packages/@n8n/utils": [ + "src/__tests__/setup.ts", + "src/assert.ts", + "src/client-assertion.test.ts" + ], + "packages/@n8n/workflow-sdk": [ + "src/__tests__/fixtures-download.ts", + "src/ast-interpreter/errors.ts", + "src/ast-interpreter/interpreter.test.ts", + "src/prompts/best-practices/guides/chatbot.ts", + "src/prompts/node-guidance/parameter-guides/embedding-nodes.ts" + ], + "packages/cli": [ + "src/__tests__/abstract-server.test.ts", + "src/abstract-server.ts", + "src/active-workflow-manager.ts", + "src/binary-data/database.manager.ts", + "src/commands/db/revert.ts", + "src/commands/import/credentials.ts", + "src/commands/ldap/reset.ts", + "src/controllers/project.controller.ts", + "src/credentials-helper.ts", + "src/credentials/credential-connection-status-provider.interface.ts", + "src/credentials/credential-connection-status-proxy.ts", + "src/credentials/credential-dependency.service.ts", + "src/credentials/credentials-finder.service.ts", + "src/credentials/credentials.controller.ts", + "src/credentials/credentials.service.ee.ts", + "src/credentials/credentials.service.ts", + "src/databases/repositories/__tests__/credentials.repository.test.ts", + "src/evaluation.ee/evaluation-collection.service.ts", + "src/evaluation.ee/test-runner/test-runner.service.ee.ts", + "src/eventbus/message-event-bus/message-event-bus.ts", + "src/events/relays/telemetry.event-relay.ts", + "src/executions/execution-data/db-store.ts", + "src/executions/execution-persistence.ts", + "src/executions/execution-recovery.service.ts", + "src/executions/execution.service.ts", + "src/instance-settings-loader/loaders/log-streaming.instance-settings-loader.ts", + "src/license.ts", + "src/metrics/prometheus/instance-role-metrics.service.ts", + "src/modules/agent-evals/agent-evals.module.ts", + "src/modules/agents/agent-knowledge.service.ts", + "src/modules/agents/agent-publish.service.ts", + "src/modules/agents/agent-task.service.ts", + "src/modules/agents/agents.service.ts", + "src/modules/agents/builder/agents-builder.service.ts", + "src/modules/agents/entities/agent-background-job.entity.ts", + "src/modules/agents/instance-ai-builder-delegate.adapter.ts", + "src/modules/agents/integrations/agent-channel-reconciler.service.ts", + "src/modules/agents/integrations/leader-channel-relay.service.ts", + "src/modules/agents/integrations/n8n-memory.ts", + "src/modules/agents/integrations/platforms/discord-integration.ts", + "src/modules/agents/repositories/agent-background-job.repository.ts", + "src/modules/agents/tools/workflow-tool-workflow-resolver.ts", + "src/modules/breaking-changes/breaking-changes.service.ts", + "src/modules/chat-hub/chat-hub-agent.service.ts", + "src/modules/chat-hub/chat-hub-credentials.service.ts", + "src/modules/chat-hub/chat-hub-title.service.ts", + "src/modules/chat-hub/chat-hub-tool.service.ts", + "src/modules/chat-hub/chat-hub-workflow.service.ts", + "src/modules/chat-hub/chat-hub.attachment.service.ts", + "src/modules/chat-hub/chat-hub.models.service.ts", + "src/modules/chat-hub/chat-hub.service.ts", + "src/modules/chat-hub/chat-hub.settings.service.ts", + "src/modules/data-table/data-table-ddl.service.ts", + "src/modules/data-table/data-table.service.ts", + "src/modules/data-table/utils/sql-utils.ts", + "src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts", + "src/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository.ts", + "src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts", + "src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts", + "src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts", + "src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts", + "src/modules/favorites/favorites.service.ts", + "src/modules/insights/insights-collection.service.ts", + "src/modules/instance-ai/__tests__/agent-preview-handoff.test.ts", + "src/modules/instance-ai/instance-ai.adapter.service.ts", + "src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts", + "src/modules/instance-ai/storage/typeorm-agent-checkpoint-store.ts", + "src/modules/instance-ai/storage/typeorm-agent-memory.ts", + "src/modules/instance-ai/storage/typeorm-observation-log-store.ts", + "src/modules/instance-ai/suspended-thread-persistence.service.ts", + "src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts", + "src/modules/log-streaming.ee/log-streaming-destination.service.ts", + "src/modules/mcp/mcp-api-key.service.ts", + "src/modules/mcp/mcp.settings.service.ts", + "src/modules/oauth-jwe/oauth-jwe-key.service.ts", + "src/modules/provisioning.ee/provisioning.service.ee.ts", + "src/modules/provisioning.ee/role-mapping-rule.service.ee.ts", + "src/modules/provisioning.ee/role-resolver.service.ee.ts", + "src/modules/source-control.ee/source-control-context.factory.ts", + "src/modules/source-control.ee/source-control-export.service.ee.ts", + "src/modules/source-control.ee/source-control-import.service.ee.ts", + "src/modules/source-control.ee/source-control-scoped.service.ts", + "src/modules/source-control.ee/source-control-status.service.ee.ts", + "src/modules/token-exchange/services/trusted-key.service.ts", + "src/modules/workflow-index/workflow-dependency-query.service.ts", + "src/permissions.ee/check-access.ts", + "src/policy/policy-enforcement.service.ts", + "src/public-api/v1/controllers/__tests__/workflows.public.controller.test.ts", + "src/public-api/v1/handlers/audit/audit.handler.ts", + "src/public-api/v1/handlers/community-packages/community-packages.handler.ts", + "src/public-api/v1/handlers/credentials/credentials.handler.ts", + "src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts", + "src/public-api/v1/handlers/data-tables/data-tables.handler.ts", + "src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts", + "src/public-api/v1/handlers/discover/discover.handler.ts", + "src/public-api/v1/handlers/evaluations/evaluations.handler.ts", + "src/public-api/v1/handlers/folders/folders.handler.ts", + "src/public-api/v1/handlers/insights/insights.handler.ts", + "src/public-api/v1/handlers/ldap/ldap.handler.ts", + "src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts", + "src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts", + "src/public-api/v1/handlers/otel/otel.handler.ts", + "src/public-api/v1/handlers/projects/projects.handler.ts", + "src/public-api/v1/handlers/security-policy/security-policy.handler.ts", + "src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts", + "src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts", + "src/public-api/v1/handlers/tags/tags.handler.ts", + "src/public-api/v1/handlers/users/users.handler.ee.ts", + "src/public-api/v1/handlers/variables/variables.handler.ts", + "src/public-api/v1/handlers/workflows/workflows.handler.ts", + "src/scaling/scaling.service.ts", + "src/scheduling/durable-job-provisioner.ts", + "src/scheduling/durable-scheduler.ts", + "src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts", + "src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts", + "src/scheduling/system-tasks/system-task-runner.ts", + "src/security-audit/risk-reporters/credentials-risk-reporter.ts", + "src/security-audit/security-audit.repository.ts", + "src/services/export.service.ts", + "src/services/folder-finder.service.ts", + "src/services/folder.service.ts", + "src/services/hooks.service.ts", + "src/services/import.service.ts", + "src/services/ownership-transfer/ownership-transfer-handler.registry.ts", + "src/services/ownership.service.ts", + "src/services/project.service.ee.ts", + "src/services/pruning/executions-pruning.service.ts", + "src/services/pruning/workflow-history-compaction.service.ts", + "src/services/public-api-key.service.ts", + "src/services/role-cache.service.ts", + "src/services/role.service.ts", + "src/services/tag.service.ts", + "src/services/user.service.ts", + "src/services/workflow-statistics-rollup.service.ts", + "src/wait-tracker.ts", + "src/workflows/publication/workflow-publication-outbox-consumer.ts", + "src/workflows/publication/workflow-publication-reconciler.service.ts", + "src/workflows/workflow-creation.service.ts", + "src/workflows/workflow-finder.service.ts", + "src/workflows/workflow-history/workflow-history.service.ts", + "src/workflows/workflow-sharing.service.ts", + "src/workflows/workflow-validation.service.ts", + "src/workflows/workflow.service.ee.ts", + "src/workflows/workflow.service.ts", + "src/workflows/workflows.controller.ts", + "test/extend-expect.ts" + ], + "packages/core": [ + "src/__tests__/credentials.test.ts", + "src/binary-data/binary-data.config.ts", + "test/helpers/constants.ts" + ], + "packages/extensions/insights": [ + "src/backend/index.ts", + "src/frontend/InsightsDashboard.vue", + "src/frontend/index.ts" + ], + "packages/frontend/@n8n/chat": [ + "src/App.vue", + "src/__stories__/App.stories.ts", + "src/__tests__/Chat.spec.ts", + "src/__tests__/plugins/chat.test.ts" + ], + "packages/frontend/@n8n/composables": [ + "src/__tests__/packageBoundary.test.ts", + "src/injectionKeys.ts" + ], + "packages/frontend/@n8n/design-system": [ + "src/__tests__/render.ts", + "src/components/AskAssistantAvatar/AskAssistantAvatar.test.ts", + "src/components/AskAssistantAvatar/AssistantAvatar.stories.ts", + "src/components/AskAssistantAvatar/AssistantAvatar.vue", + "src/components/N8nFormInput/validators.ts" + ], + "packages/frontend/@n8n/eslint-plugin-design-system": [ + "src/plugin.ts", + "src/rules/require-teleported-tooltip-in-dropdown.test.ts" + ], + "packages/frontend/@n8n/frontend-constants": ["src/urls.ts", "src/views.test.ts", "src/views.ts"], + "packages/frontend/@n8n/frontend-module-sdk": [ + "src/__tests__/setup.ts", + "src/index.ts", + "src/registries/commandRegistry.test.ts" + ], + "packages/frontend/@n8n/frontend-test-utils": ["src/__tests__/render.test.ts", "src/defaults.ts"], + "packages/frontend/@n8n/frontend-utils": [ + "src/__tests__/setup.ts", + "src/constants/sanitization.ts", + "src/htmlUtils.test.ts" + ], + "packages/frontend/@n8n/i18n": ["src/__tests__/setup.ts", "src/index.test.ts", "src/index.ts"], + "packages/frontend/@n8n/rest-api-client": [ + "src/__tests__/setup.ts", + "src/api/__tests__/api-keys.test.ts", + "src/api/ai-usage.ts" + ], + "packages/frontend/@n8n/stores": [ + "src/__tests__/metaTagConfig.test.ts", + "src/cloudPlan.store.ts" + ], + "packages/frontend/@n8n/storybook": [".storybook/ThemedDocsContainer.ts"], + "packages/frontend/editor-ui": [ + "src/Interface.ts", + "src/__tests__/data/users.ts", + "src/app/App.vue", + "src/app/__tests__/modal-definition.test.ts", + "src/app/constants/modals.ts", + "src/app/stores/defaults/modals.ts", + "src/app/stores/workflowDocument/types.ts", + "src/app/stores/workflows.store.ts", + "src/features/agents/__tests__/AgentAdvancedPanel.test.ts", + "src/features/agents/channels/AgentChannelStandardEditView.vue", + "src/features/ai/chatHub/__test__/data.ts", + "src/features/settings/environments.ee/completions/variables.completions.test.ts", + "src/features/shared/editors/components/CodeNodeEditor/CodeNodeEditor.vue", + "src/features/shared/editors/plugins/codemirror/completions/__tests__/mock.ts", + "src/features/shared/toolsConnection/ToolsConnectionModal.stories.ts" + ], + "packages/modules/insights/frontend": [ + "src/__tests__/setup.ts", + "src/chartjs.utils.ts", + "src/components/InsightsDashboard.test.ts", + "src/components/InsightsDashboard.vue" + ], + "packages/modules/instance-registry/frontend": [ + "src/__tests__/setup.ts", + "src/index.ts", + "src/instance-registry.store.test.ts" + ], + "packages/modules/otel/frontend": [ + "src/OtelSettingsRow.vue", + "src/SettingsOpenTelemetryView.test.ts", + "src/__tests__/setup.ts", + "src/index.ts" + ], + "packages/node-dev": ["src/Build.ts"], + "packages/nodes-base": [ + "credentials/ActionNetworkApi.credentials.ts", + "credentials/common/aws/s3-path-signing.test.ts", + "credentials/test/AtlassianOAuth2Api.credentials.test.ts", + "nodes/ActionNetwork/ActionNetwork.node.ts", + "nodes/Aws/__tests__/AwsLambda.node.test.ts" + ], + "packages/testing/code-health": ["src/cli.ts", "src/cli/arg-parser.test.ts"], + "packages/testing/janitor": ["src/cli.test.ts", "src/cli.ts"], + "packages/testing/playwright": ["coverage-options.test.ts"], + "packages/testing/rules-engine": ["src/ast/ast-rule.ts", "src/ast/imports.test.ts"], + "packages/testing/test-impact": ["src/changes.test.ts", "src/changes.ts"], + "packages/workflow": [ + "src/augment-object.ts", + "test/ExpressionExtensions/array-extensions.test.ts" + ] +}