diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 5b2d11170af..cbb14852774 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -20,9 +20,9 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.agent_execution_threads](public.agent_execution_threads.md) | 17 | | BASE TABLE | | [public.agent_files](public.agent_files.md) | 10 | | BASE TABLE | | [public.agent_history](public.agent_history.md) | 9 | | BASE TABLE | -| [public.agent_task_definition](public.agent_task_definition.md) | 7 | | BASE TABLE | +| [public.agent_task_definition](public.agent_task_definition.md) | 8 | | BASE TABLE | | [public.agent_task_run_lock](public.agent_task_run_lock.md) | 6 | | BASE TABLE | -| [public.agent_task_snapshot](public.agent_task_snapshot.md) | 8 | | BASE TABLE | +| [public.agent_task_snapshot](public.agent_task_snapshot.md) | 9 | | BASE TABLE | | [public.agents](public.agents.md) | 14 | | BASE TABLE | | [public.agents_memory_entries](public.agents_memory_entries.md) | 13 | | BASE TABLE | | [public.agents_memory_entry_cursors](public.agents_memory_entry_cursors.md) | 6 | | BASE TABLE | @@ -507,6 +507,7 @@ erDiagram varchar_32_ id varchar_128_ name text objective + varchar_64_ timezone timestamp_3__with_time_zone updatedAt } "public.agent_task_run_lock" { @@ -524,6 +525,7 @@ erDiagram varchar_128_ name text objective varchar_32_ taskId + varchar_64_ timezone timestamp_3__with_time_zone updatedAt varchar_36_ versionId FK } diff --git a/docs/generated/postgres-schema/public.agent_history.md b/docs/generated/postgres-schema/public.agent_history.md index 42c02e64657..6419faf1b68 100644 --- a/docs/generated/postgres-schema/public.agent_history.md +++ b/docs/generated/postgres-schema/public.agent_history.md @@ -115,6 +115,7 @@ erDiagram varchar_128_ name text objective varchar_32_ taskId + varchar_64_ timezone timestamp_3__with_time_zone updatedAt varchar_36_ versionId FK } diff --git a/docs/generated/postgres-schema/public.agent_task_definition.md b/docs/generated/postgres-schema/public.agent_task_definition.md index f08986d7d7b..6be5911a4c3 100644 --- a/docs/generated/postgres-schema/public.agent_task_definition.md +++ b/docs/generated/postgres-schema/public.agent_task_definition.md @@ -10,6 +10,7 @@ | id | varchar(32) | | false | | | Application-generated task ID referenced from agent JSON config | | name | varchar(128) | | false | | | | | objective | text | | false | | | User-authored instruction sent to the agent when this task runs | +| timezone | varchar(64) | | true | | | IANA timezone the cron is evaluated in; null falls back to the instance timezone | | updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | ## Constraints @@ -47,6 +48,7 @@ erDiagram varchar_32_ id varchar_128_ name text objective + varchar_64_ timezone timestamp_3__with_time_zone updatedAt } "public.agents" { diff --git a/docs/generated/postgres-schema/public.agent_task_snapshot.md b/docs/generated/postgres-schema/public.agent_task_snapshot.md index 1c09787a408..12b87284ce1 100644 --- a/docs/generated/postgres-schema/public.agent_task_snapshot.md +++ b/docs/generated/postgres-schema/public.agent_task_snapshot.md @@ -10,6 +10,7 @@ | name | varchar(128) | | false | | | | | objective | text | | false | | | User-authored instruction sent to the agent when this task runs | | taskId | varchar(32) | | false | | | Stable task ID referenced from the published agent JSON config | +| timezone | varchar(64) | | true | | | IANA timezone the cron is evaluated in; null falls back to the instance timezone | | updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | | versionId | varchar(36) | | false | | [public.agent_history](public.agent_history.md) | Published agent_history version this task snapshot belongs to | @@ -48,6 +49,7 @@ erDiagram varchar_128_ name text objective varchar_32_ taskId + varchar_64_ timezone timestamp_3__with_time_zone updatedAt varchar_36_ versionId FK } diff --git a/docs/generated/postgres-schema/public.agents.md b/docs/generated/postgres-schema/public.agents.md index 1e0554bbcb9..bab2f33dba8 100644 --- a/docs/generated/postgres-schema/public.agents.md +++ b/docs/generated/postgres-schema/public.agents.md @@ -182,6 +182,7 @@ erDiagram varchar_32_ id varchar_128_ name text objective + varchar_64_ timezone timestamp_3__with_time_zone updatedAt } "public.agent_task_run_lock" { diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 5d77d398205..ce7b56753ca 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -20,9 +20,9 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [agent_execution_threads](agent_execution_threads.md) | 17 | | table | | [agent_files](agent_files.md) | 10 | | table | | [agent_history](agent_history.md) | 9 | | table | -| [agent_task_definition](agent_task_definition.md) | 7 | | table | +| [agent_task_definition](agent_task_definition.md) | 8 | | table | | [agent_task_run_lock](agent_task_run_lock.md) | 6 | | table | -| [agent_task_snapshot](agent_task_snapshot.md) | 8 | | table | +| [agent_task_snapshot](agent_task_snapshot.md) | 9 | | table | | [agents](agents.md) | 14 | | table | | [agents_memory_entries](agents_memory_entries.md) | 13 | | table | | [agents_memory_entry_cursors](agents_memory_entry_cursors.md) | 6 | | table | @@ -494,6 +494,7 @@ erDiagram varchar_32_ id PK varchar_128_ name TEXT objective + varchar_64_ timezone datetime_3_ updatedAt } "agent_task_run_lock" { @@ -511,6 +512,7 @@ erDiagram varchar_128_ name TEXT objective varchar_32_ taskId PK + varchar_64_ timezone datetime_3_ updatedAt varchar_36_ versionId PK } diff --git a/docs/generated/sqlite-schema/agent_history.md b/docs/generated/sqlite-schema/agent_history.md index 09c1d6d3410..4f6b05a5b8d 100644 --- a/docs/generated/sqlite-schema/agent_history.md +++ b/docs/generated/sqlite-schema/agent_history.md @@ -122,6 +122,7 @@ erDiagram varchar_128_ name TEXT objective varchar_32_ taskId PK + varchar_64_ timezone datetime_3_ updatedAt varchar_36_ versionId PK } diff --git a/docs/generated/sqlite-schema/agent_task_definition.md b/docs/generated/sqlite-schema/agent_task_definition.md index 762995f3535..48bac946715 100644 --- a/docs/generated/sqlite-schema/agent_task_definition.md +++ b/docs/generated/sqlite-schema/agent_task_definition.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "agent_task_definition" ("id" varchar(32) PRIMARY KEY NOT NULL, "agentId" varchar(36) NOT NULL, "name" varchar(128) NOT NULL, "objective" text NOT NULL, "cronExpression" varchar(128) NOT NULL, "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 "FK_f45d0535a2ed59b6c2dd6da98a0" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE) +CREATE TABLE "agent_task_definition" ("id" varchar(32) PRIMARY KEY NOT NULL, "agentId" varchar(36) NOT NULL, "name" varchar(128) NOT NULL, "objective" text NOT NULL, "cronExpression" varchar(128) NOT NULL, "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')), "timezone" varchar(64), CONSTRAINT "FK_f45d0535a2ed59b6c2dd6da98a0" FOREIGN KEY ("agentId") REFERENCES "agents" ("id") ON DELETE CASCADE ON UPDATE NO ACTION) ``` @@ -21,6 +21,7 @@ CREATE TABLE "agent_task_definition" ("id" varchar(32) PRIMARY KEY NOT NULL, "ag | id | varchar(32) | | false | | | | | name | varchar(128) | | false | | | | | objective | TEXT | | false | | | | +| timezone | varchar(64) | | true | | | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | ## Constraints @@ -52,6 +53,7 @@ erDiagram varchar_32_ id PK varchar_128_ name TEXT objective + varchar_64_ timezone datetime_3_ updatedAt } "agents" { diff --git a/docs/generated/sqlite-schema/agent_task_snapshot.md b/docs/generated/sqlite-schema/agent_task_snapshot.md index 96801b8dd5f..5c8d65ea6c9 100644 --- a/docs/generated/sqlite-schema/agent_task_snapshot.md +++ b/docs/generated/sqlite-schema/agent_task_snapshot.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "agent_task_snapshot" ("versionId" varchar(36) NOT NULL, "taskId" varchar(32) NOT NULL, "enabled" boolean NOT NULL, "name" varchar(128) NOT NULL, "objective" text NOT NULL, "cronExpression" varchar(128) NOT NULL, "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 "FK_1acedce6690392ef1611cca8b88" FOREIGN KEY ("versionId") REFERENCES "agent_history" ("versionId") ON DELETE CASCADE, PRIMARY KEY ("versionId", "taskId")) +CREATE TABLE "agent_task_snapshot" ("versionId" varchar(36) NOT NULL, "taskId" varchar(32) NOT NULL, "enabled" boolean NOT NULL, "name" varchar(128) NOT NULL, "objective" text NOT NULL, "cronExpression" varchar(128) NOT NULL, "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')), "timezone" varchar(64), CONSTRAINT "FK_1acedce6690392ef1611cca8b88" FOREIGN KEY ("versionId") REFERENCES "agent_history" ("versionId") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("versionId", "taskId")) ``` @@ -21,6 +21,7 @@ CREATE TABLE "agent_task_snapshot" ("versionId" varchar(36) NOT NULL, "taskId" v | name | varchar(128) | | false | | | | | objective | TEXT | | false | | | | | taskId | varchar(32) | | false | | | | +| timezone | varchar(64) | | true | | | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | | versionId | varchar(36) | | false | | [agent_history](agent_history.md) | | @@ -53,6 +54,7 @@ erDiagram varchar_128_ name TEXT objective varchar_32_ taskId PK + varchar_64_ timezone datetime_3_ updatedAt varchar_36_ versionId PK } diff --git a/docs/generated/sqlite-schema/agents.md b/docs/generated/sqlite-schema/agents.md index 949e04183c2..fea91cc65be 100644 --- a/docs/generated/sqlite-schema/agents.md +++ b/docs/generated/sqlite-schema/agents.md @@ -184,6 +184,7 @@ erDiagram varchar_32_ id PK varchar_128_ name TEXT objective + varchar_64_ timezone datetime_3_ updatedAt } "agent_task_run_lock" { diff --git a/packages/@n8n/api-types/src/agents/agent-task.schema.ts b/packages/@n8n/api-types/src/agents/agent-task.schema.ts index 1648daee462..bf67e79983f 100644 --- a/packages/@n8n/api-types/src/agents/agent-task.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-task.schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { StrictTimeZoneSchema } from '../schemas/timezone.schema'; + export const AGENT_TASK_NAME_MAX_LENGTH = 128; export const AGENT_TASK_ID_MAX_LENGTH = 32; export const AGENT_TASK_OBJECTIVE_MAX_LENGTH = 10_000; @@ -18,6 +20,9 @@ export const agentTaskSchema = z.object({ .min(1) .max(AGENT_TASK_CRON_EXPRESSION_MAX_LENGTH) .describe('Standard five-field cron expression, for example "0 9 * * *"'), + timezone: StrictTimeZoneSchema.describe( + 'IANA timezone the cron is evaluated in, for example "Europe/London". Omit to use the instance timezone.', + ).nullish(), }); export type AgentTaskConfig = z.infer; diff --git a/packages/@n8n/api-types/src/agents/dto.ts b/packages/@n8n/api-types/src/agents/dto.ts index fbb8a39a819..14b9df4d392 100644 --- a/packages/@n8n/api-types/src/agents/dto.ts +++ b/packages/@n8n/api-types/src/agents/dto.ts @@ -100,6 +100,7 @@ export class CreateAgentTaskDto extends Z.class({ name: agentTaskSchema.shape.name, objective: agentTaskSchema.shape.objective, cronExpression: agentTaskSchema.shape.cronExpression, + timezone: agentTaskSchema.shape.timezone, // Seeds the config ref's enabled flag; the task body itself has no enabled. enabled: z.boolean().optional().default(true), }) {} @@ -108,6 +109,8 @@ export class UpdateAgentTaskDto extends Z.class({ name: agentTaskSchema.shape.name.optional(), objective: agentTaskSchema.shape.objective.optional(), cronExpression: agentTaskSchema.shape.cronExpression.optional(), + // `null` explicitly resets the task to the instance timezone. + timezone: agentTaskSchema.shape.timezone, }) {} const updateAgentSkillShape = { diff --git a/packages/@n8n/db/src/migrations/common/1787057050000-AddTimezoneToAgentTasks.ts b/packages/@n8n/db/src/migrations/common/1787057050000-AddTimezoneToAgentTasks.ts new file mode 100644 index 00000000000..928b4aa3d05 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1787057050000-AddTimezoneToAgentTasks.ts @@ -0,0 +1,31 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +/** + * Lets a scheduled agent task carry the timezone its cron is evaluated in, so a + * task authored as "Friday 08:00" keeps firing at 08:00 in the author's zone + * across DST instead of drifting with the instance timezone. Null on existing + * rows, which keeps them on the instance timezone they were scheduled with. + */ +export class AddTimezoneToAgentTasks1787057050000 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + for (const table of ['agent_task_definition', 'agent_task_snapshot']) { + await addColumns( + table, + [ + column('timezone') + .varchar(64) + .comment( + 'IANA timezone the cron is evaluated in; null falls back to the instance timezone', + ), + ], + { recreatesOnSqlite: true }, + ); + } + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + for (const table of ['agent_task_definition', 'agent_task_snapshot']) { + await dropColumns(table, ['timezone'], { recreatesOnSqlite: true }); + } + } +} diff --git a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts index 988919f7557..dbeb9b72b9e 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-task.service.test.ts @@ -36,6 +36,8 @@ function makeTask(overrides: Partial = {}): AgentTask { name: 'Daily summary', objective: 'Summarize messages', cronExpression: '0 9 * * *', + // Null is the pre-existing shape: the cron runs on the instance timezone. + timezone: null, createdAt: new Date('2026-01-01T08:00:00.000Z'), updatedAt: new Date('2026-01-02T08:00:00.000Z'), ...overrides, @@ -80,6 +82,7 @@ function makeSnapshot(overrides: Partial = {}): AgentTaskSnap name: 'Daily summary', objective: 'Summarize messages', cronExpression: '0 9 * * *', + timezone: null, createdAt: new Date('2026-01-01T08:00:00.000Z'), updatedAt: new Date('2026-01-02T08:00:00.000Z'), ...overrides, @@ -232,6 +235,8 @@ describe('AgentTaskService', () => { name: 'Daily', objective: 'Do X', cronExpression: '0 9 * * *', + // No timezone given, so the task follows the instance timezone. + timezone: null, }), ); expect(agent.schema?.tasks).toEqual([ @@ -258,6 +263,50 @@ describe('AgentTaskService', () => { expect(taskRepository.create).not.toHaveBeenCalled(); }); + it('persists the timezone the schedule was authored in', async () => { + (agentRepository.findByIdAndProjectId as Mock).mockResolvedValue( + makeAgent({ + schema: { name: 'a', model: 'm', instructions: 'i', tasks: [] }, + } as Partial), + ); + + const dto = await service.create( + AGENT_ID, + PROJECT_ID, + { + name: 'Daily', + objective: 'Do X', + cronExpression: '0 8 * * *', + timezone: 'Europe/London', + enabled: true, + }, + telemetryContext, + ); + + expect(dto.timezone).toBe('Europe/London'); + expect(taskRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ cronExpression: '0 8 * * *', timezone: 'Europe/London' }), + ); + }); + + it('rejects an unknown timezone without creating', async () => { + await expect( + service.create( + AGENT_ID, + PROJECT_ID, + { + name: 'x', + objective: 'y', + cronExpression: '0 9 * * *', + timezone: 'Mars/Olympus_Mons', + enabled: true, + }, + telemetryContext, + ), + ).rejects.toThrow(BadRequestError); + expect(taskRepository.create).not.toHaveBeenCalled(); + }); + it('does not register a cron job on create (scheduling follows publish)', async () => { (agentRepository.findByIdAndProjectId as Mock).mockResolvedValue( makeAgent({ @@ -397,10 +446,16 @@ describe('AgentTaskService', () => { }); describe('update', () => { - it('updates body fields without registering a cron (publish-driven)', async () => { - const task = makeTask(); + /** The task row `update` mutates, with both lookups it performs stubbed. */ + function arrangeUpdate(overrides: Partial = {}): AgentTask { + const task = makeTask(overrides); (taskRepository.findByIdAndAgentId as Mock).mockResolvedValue(task); (agentRepository.findByIdAndProjectId as Mock).mockResolvedValue(makeAgent()); + return task; + } + + it('updates body fields without registering a cron (publish-driven)', async () => { + const task = arrangeUpdate({ timezone: 'Asia/Tokyo' }); const dto = await service.update( AGENT_ID, @@ -412,10 +467,59 @@ describe('AgentTaskService', () => { expect(dto.cronExpression).toBe('0 10 * * *'); expect(task.cronExpression).toBe('0 10 * * *'); + // An omitted timezone keeps the current one, so old clients stay safe. + expect(task.timezone).toBe('Asia/Tokyo'); expect(txManager.save).toHaveBeenCalled(); expect(agentTaskScheduler.register).not.toHaveBeenCalled(); }); + it('moves the schedule to another timezone', async () => { + const task = arrangeUpdate(); + + const dto = await service.update( + AGENT_ID, + PROJECT_ID, + 'task-1', + { timezone: 'Asia/Tokyo' }, + telemetryContext, + ); + + expect(dto.timezone).toBe('Asia/Tokyo'); + expect(task.timezone).toBe('Asia/Tokyo'); + expect(txManager.save).toHaveBeenCalled(); + }); + + it('resets to the instance timezone when passed null', async () => { + const task = arrangeUpdate({ timezone: 'Asia/Tokyo' }); + + const dto = await service.update( + AGENT_ID, + PROJECT_ID, + 'task-1', + { timezone: null }, + telemetryContext, + ); + + expect(dto.timezone).toBeNull(); + expect(task.timezone).toBeNull(); + }); + + it('rejects an unknown timezone without writing', async () => { + const task = arrangeUpdate({ timezone: 'Asia/Tokyo' }); + + await expect( + service.update( + AGENT_ID, + PROJECT_ID, + 'task-1', + { timezone: 'Mars/Olympus_Mons' }, + telemetryContext, + ), + ).rejects.toThrow(BadRequestError); + expect(task.timezone).toBe('Asia/Tokyo'); + expect(txManager.save).not.toHaveBeenCalled(); + }); + it('is a no-op when no field changes (skips the agent write)', async () => { const task = makeTask(); (taskRepository.findByIdAndAgentId as Mock).mockResolvedValue(task); @@ -539,6 +643,47 @@ describe('AgentTaskService', () => { ); }); + it("registers the cron in the snapshot's own timezone", async () => { + (agentRepository.findOne as Mock).mockResolvedValue( + makePublishedAgent([{ id: 'task-1', enabled: true }]), + ); + (taskSnapshotRepository.findEnabledByVersionId as Mock).mockResolvedValue([ + makeSnapshot({ cronExpression: '0 8 * * 5', timezone: 'Europe/London' }), + ]); + + await service.registerEnabledForAgent(AGENT_ID); + + expect(agentTaskScheduler.register).toHaveBeenCalledWith( + { + group: agentTaskGroup(), + targetId: 'task-1', + expression: '0 8 * * 5', + timezone: 'Europe/London', + }, + expect.any(Function), + ); + }); + + it('falls back to the instance timezone when the stored timezone is unknown', async () => { + (agentRepository.findOne as Mock).mockResolvedValue( + makePublishedAgent([{ id: 'task-1', enabled: true }]), + ); + (taskSnapshotRepository.findEnabledByVersionId as Mock).mockResolvedValue([ + makeSnapshot({ timezone: 'Mars/Olympus_Mons' }), + ]); + + await service.registerEnabledForAgent(AGENT_ID); + + expect(agentTaskScheduler.register).toHaveBeenCalledWith( + expect.objectContaining({ timezone: 'UTC' }), + expect.any(Function), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('unknown timezone'), + expect.objectContaining({ timezone: 'Mars/Olympus_Mons' }), + ); + }); + it('does not register tasks disabled in the published config', async () => { (agentRepository.findOne as Mock).mockResolvedValue( makePublishedAgent([{ id: 'task-1', enabled: false }]), @@ -796,6 +941,27 @@ describe('AgentTaskService', () => { expect(taskRepository.findOne).not.toHaveBeenCalled(); }); + it('names the schedule timezone without moving the timestamp off the instance zone', async () => { + (agentRepository.findOne as Mock).mockResolvedValue( + makePublishedAgent([{ id: 'task-1', enabled: true }]), + ); + (taskSnapshotRepository.findByVersionAndTaskId as Mock).mockResolvedValue( + makeSnapshot({ timezone: 'Asia/Tokyo' }), + ); + (agentExecutionOrchestratorService.executeForTaskPublished as Mock).mockReturnValue( + emptyStream(), + ); + + await runTaskOf(service, AGENT_ID, 'task-1'); + + // The timestamp has to agree with the `get_environment` tool, which reports + // the instance zone, so the schedule's zone is named instead of substituted. + const { message } = (agentExecutionOrchestratorService.executeForTaskPublished as Mock).mock + .calls[0][0] as { message: string }; + expect(message).toContain('(timezone: UTC)'); + expect(message).toContain('This task is scheduled in Asia/Tokyo.'); + }); + it('skips when the agent is unpublished', async () => { (agentRepository.findOne as Mock).mockResolvedValue(makeAgent({ activeVersionId: null })); diff --git a/packages/cli/src/modules/agents/agent-publish.service.ts b/packages/cli/src/modules/agents/agent-publish.service.ts index e4e4d93563e..5ca5c5054db 100644 --- a/packages/cli/src/modules/agents/agent-publish.service.ts +++ b/packages/cli/src/modules/agents/agent-publish.service.ts @@ -666,6 +666,7 @@ export class AgentPublishService { name: body.name, objective: body.objective, cronExpression: body.cronExpression, + timezone: body.timezone, }; }), trx, @@ -694,7 +695,8 @@ export class AgentPublishService { /** * Bring the draft task definition rows back in line with a published snapshot - * on revert. Returns whether task bodies changed (name/objective/cron only). + * on revert. Returns whether task bodies changed (name/objective/cron/timezone + * only). */ private async restoreTasksFromSnapshot( trx: EntityManager, @@ -708,7 +710,12 @@ export class AgentPublishService { const existingBodies = Object.fromEntries( existing.map((row) => [ row.id, - { name: row.name, objective: row.objective, cronExpression: row.cronExpression }, + { + name: row.name, + objective: row.objective, + cronExpression: row.cronExpression, + timezone: row.timezone, + }, ]), ); const snapshotBodies = Object.fromEntries( @@ -718,6 +725,7 @@ export class AgentPublishService { name: snapshot.name, objective: snapshot.objective, cronExpression: snapshot.cronExpression, + timezone: snapshot.timezone, }, ]), ); @@ -735,6 +743,7 @@ export class AgentPublishService { name: snapshot.name, objective: snapshot.objective, cronExpression: snapshot.cronExpression, + timezone: snapshot.timezone, }); } else { await repo.insert({ @@ -743,6 +752,7 @@ export class AgentPublishService { name: snapshot.name, objective: snapshot.objective, cronExpression: snapshot.cronExpression, + timezone: snapshot.timezone, }); } } diff --git a/packages/cli/src/modules/agents/agent-task.service.ts b/packages/cli/src/modules/agents/agent-task.service.ts index 2bdde0b250b..7c1e817ef8e 100644 --- a/packages/cli/src/modules/agents/agent-task.service.ts +++ b/packages/cli/src/modules/agents/agent-task.service.ts @@ -1,4 +1,5 @@ import type { AgentTaskDto, CreateAgentTaskDto, UpdateAgentTaskDto } from '@n8n/api-types'; +import { isValidTimeZone } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; @@ -22,6 +23,7 @@ import { import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service'; import { Agent } from './entities/agent.entity'; import { AgentTask } from './entities/agent-task.entity'; +import type { AgentTaskSnapshot } from './entities/agent-task-snapshot.entity'; import { isValidCronExpression } from './integrations/cron-validation'; import { AgentRepository } from './repositories/agent.repository'; import { @@ -45,8 +47,9 @@ const agentTaskScheduleGroup = (agentId: string): ScheduledTaskGroup => ({ }); /** - * Owns an agent's scheduled tasks. Draft task bodies (name/objective/cron) live - * in the `agent_task_definition` table; membership and the `enabled` flag live + * Owns an agent's scheduled tasks. Draft task bodies (name/objective/cron and + * the timezone that cron is evaluated in — null meaning the instance timezone) + * live in the `agent_task_definition` table; membership and the `enabled` flag live * in the agent config as `{ type: 'task', id, enabled }` refs (mirroring * skills). Scheduling is driven entirely by the PUBLISHED snapshot rows tied to * `activeVersionId`. `ScheduledTaskManager` registers a cron per enabled @@ -127,6 +130,7 @@ export class AgentTaskService { for (const dto of dtos) { this.assertValidCron(dto.cronExpression); + this.assertValidTimezone(dto.timezone); } const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); @@ -149,6 +153,7 @@ export class AgentTaskService { name: dto.name, objective: dto.objective, cronExpression: dto.cronExpression, + timezone: dto.timezone ?? null, }); }); @@ -184,7 +189,7 @@ export class AgentTaskService { } /** - * Update a task body (name/objective/cron) in the draft. Marks the agent draft + * Update a task body (name/objective/cron/timezone) in the draft. Marks the agent draft * dirty but does NOT touch live scheduling: scheduled runs read the published * task snapshot rows, so any body edit only takes effect on the next * (re)publish — the "republish to apply" contract. Manual "Run now" uses the @@ -207,6 +212,16 @@ export class AgentTaskService { changed = true; } } + // `null` resets to the instance timezone; omitting the field keeps the + // current one, so an older client can still update name/objective/cron. + if (dto.timezone !== undefined) { + this.assertValidTimezone(dto.timezone); + const timezone = dto.timezone ?? null; + if (timezone !== task.timezone) { + task.timezone = timezone; + changed = true; + } + } if (dto.name !== undefined && dto.name !== task.name) { task.name = dto.name; changed = true; @@ -416,14 +431,15 @@ export class AgentTaskService { if (enabledIds.size === 0) return; - // Bodies come from PUBLISHED snapshot rows, so cron/name/objective are all - // frozen at publish time; draft edits only apply on the next publish. + // Bodies come from PUBLISHED snapshot rows, so cron/name/objective/timezone + // are all frozen at publish time; draft edits only apply on the next publish. for (const snapshot of snapshots) { - this.registerOrRefresh(snapshot.taskId, agent.id, snapshot.cronExpression); + this.registerOrRefresh(agent.id, snapshot); } } - private registerOrRefresh(taskId: string, agentId: string, cronExpression: string): void { + private registerOrRefresh(agentId: string, snapshot: AgentTaskSnapshot): void { + const { taskId, cronExpression } = snapshot; if (!isValidCronExpression(cronExpression)) { this.logger.warn('[AgentTaskService] Skipping task with invalid cron', { taskId }); this.deregister(agentId, taskId); @@ -432,7 +448,7 @@ export class AgentTaskService { this.deregister(agentId, taskId); - const timezone = this.globalConfig.generic.timezone; + const timezone = this.resolveTaskTimezone(snapshot.timezone, taskId); const registered = this.scheduledTaskManager.register( { group: agentTaskScheduleGroup(agentId), @@ -562,13 +578,18 @@ export class AgentTaskService { return; } - const { message, threadId } = this.buildTaskRunMessage(taskId, snapshot.objective); + const { message, threadId } = this.buildTaskRunMessage( + taskId, + snapshot.objective, + snapshot.timezone, + ); this.logger.info('[AgentTaskService] Task fired', { taskId, agentId, projectId, cronExpression: snapshot.cronExpression, + timezone: snapshot.timezone, }); await this.consumeTaskRun( @@ -601,10 +622,17 @@ export class AgentTaskService { private buildTaskRunMessage( taskId: string, objective: string, + taskTimezone: string | null, ): { message: string; threadId: string } { + // Timestamped in the instance timezone so it agrees with the `get_environment` + // tool the agent also reads "today" from; the schedule's own zone is named + // instead of substituted, so the two can never contradict each other. const timezone = this.globalConfig.generic.timezone; const timestamp = DateTime.now().setZone(timezone).toISO() ?? new Date().toISOString(); - const message = `${objective}\n\nCurrent date and time: ${timestamp} (timezone: ${timezone})`; + const scheduleTimezone = this.resolveTaskTimezone(taskTimezone, taskId); + const scheduleNote = + scheduleTimezone === timezone ? '' : `\nThis task is scheduled in ${scheduleTimezone}.`; + const message = `${objective}\n\nCurrent date and time: ${timestamp} (timezone: ${timezone})${scheduleNote}`; const threadId = `task-${taskId}-${randomUUID()}`; return { message, threadId }; } @@ -655,7 +683,7 @@ export class AgentTaskService { } private async executeNow(task: AgentTask, projectId: string, user: User): Promise { - const { message, threadId } = this.buildTaskRunMessage(task.id, task.objective); + const { message, threadId } = this.buildTaskRunMessage(task.id, task.objective, task.timezone); this.logger.info('[AgentTaskService] Manual task run started', { taskId: task.id, @@ -693,12 +721,38 @@ export class AgentTaskService { } } + private assertValidTimezone(timezone: string | null | undefined): void { + if (timezone === null || timezone === undefined) return; + if (!isValidTimeZone(timezone)) { + throw new BadRequestError('Invalid timezone'); + } + } + + /** + * Timezone a task's cron is evaluated in. Null means "instance timezone" — + * the only option before tasks carried their own zone. An unresolvable zone + * falls back to the instance timezone rather than dropping the task, since + * `CronTime` would throw and take the agent's whole reconcile with it. + */ + private resolveTaskTimezone(taskTimezone: string | null | undefined, taskId: string): string { + if (!taskTimezone) return this.globalConfig.generic.timezone; + if (!isValidTimeZone(taskTimezone)) { + this.logger.warn('[AgentTaskService] Task has unknown timezone, using instance timezone', { + taskId, + timezone: taskTimezone, + }); + return this.globalConfig.generic.timezone; + } + return taskTimezone; + } + private toDto(task: AgentTask): AgentTaskDto { return { id: task.id, name: task.name, objective: task.objective, cronExpression: task.cronExpression, + timezone: task.timezone, createdAt: task.createdAt.toISOString(), updatedAt: task.updatedAt.toISOString(), }; diff --git a/packages/cli/src/modules/agents/agent-validation.service.ts b/packages/cli/src/modules/agents/agent-validation.service.ts index c66ed3963a2..ac40557db3b 100644 --- a/packages/cli/src/modules/agents/agent-validation.service.ts +++ b/packages/cli/src/modules/agents/agent-validation.service.ts @@ -12,6 +12,7 @@ import { type AgentConfigValidationIssueCode, type AgentConfigValidationResponse, type AgentIntegrationConfig, + type AgentTaskConfig, type AgentJsonConfig, type AgentJsonNodeToolConfig, type AgentJsonWorkflowToolConfig, @@ -45,7 +46,7 @@ type FindCredential = ( ) => Promise>[number] | undefined>; type CustomToolEntries = Record; -type TaskBody = { name: string; objective: string; cronExpression: string }; +type TaskBody = AgentTaskConfig; interface ConfigurationValidationContext { agentId: string; diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 7861cf59f44..b03fcda32d1 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -172,6 +172,9 @@ const updateTaskFieldsSchema = z name: agentTaskSchema.shape.name.optional(), objective: agentTaskSchema.shape.objective.optional().describe(TASK_OBJECTIVE_GUIDANCE), cronExpression: agentTaskSchema.shape.cronExpression.optional(), + timezone: agentTaskSchema.shape.timezone.describe( + 'IANA zone the cron runs in. Pass null to move the task back to the instance timezone.', + ), }) .strict() .refine((updates) => Object.keys(updates).length > 0, { @@ -1171,7 +1174,7 @@ export class AgentsBuilderToolsService { .description( 'List the target agent scheduled tasks, including each persisted body and whether its ' + 'current config reference is enabled. Use this to identify a task before updating it. Returns ' + - '{ ok: true, tasks: [{ id, name, objective, cronExpression, enabled }] } or ' + + '{ ok: true, tasks: [{ id, name, objective, cronExpression, timezone, enabled }] } or ' + '{ ok: false, errors }.', ) .input(z.object({}).strict()) @@ -1186,11 +1189,13 @@ export class AgentsBuilderToolsService { ); return { ok: true, - tasks: tasks.map(({ id, name, objective, cronExpression }) => ({ + tasks: tasks.map(({ id, name, objective, cronExpression, timezone }) => ({ id, name, objective, cronExpression, + // Null means the task runs on the instance timezone. + timezone, enabled: enabledByTaskId.get(id) ?? false, })), }; @@ -1261,6 +1266,9 @@ export class AgentsBuilderToolsService { cronExpression: agentTaskSchema.shape.cronExpression.describe( 'A 5-field cron expression for when the task runs, e.g. "0 9 * * 1-5" = weekdays at 09:00.', ), + timezone: agentTaskSchema.shape.timezone.describe( + 'IANA timezone the cron runs in, e.g. "Europe/London". Set it when the user names a timezone or a location; omit it to use the instance timezone.', + ), }), ) .min(1) @@ -1272,7 +1280,12 @@ export class AgentsBuilderToolsService { async ({ tasks, }: { - tasks: Array<{ name: string; objective: string; cronExpression: string }>; + tasks: Array<{ + name: string; + objective: string; + cronExpression: string; + timezone?: string | null; + }>; }) => { // Each task is already validated against `.input()` (agentTaskSchema // shapes) by the tool runtime before the handler runs. diff --git a/packages/cli/src/modules/agents/builder/skills/target-tasks.skill.ts b/packages/cli/src/modules/agents/builder/skills/target-tasks.skill.ts index 5aa6e2c382d..fe6c664a239 100644 --- a/packages/cli/src/modules/agents/builder/skills/target-tasks.skill.ts +++ b/packages/cli/src/modules/agents/builder/skills/target-tasks.skill.ts @@ -96,6 +96,9 @@ Initial Build rules in your system prompt. Never create a placeholder or -> "0 9 * * *"; weekdays 08:30 -> "30 8 * * 1-5"; hourly -> "0 * * * *"). Keep this cadence out of the objective; only a data lookback window belongs in its Context. +- Set \`timezone\` to the IANA zone whenever the user names a timezone or a + location ("9am in Tokyo" -> "Asia/Tokyo"); omit it to run on the instance + timezone. - Call \`create_tasks\` once with a \`tasks\` array containing every task you currently know how to write — do not spread multiple fully-specified tasks across separate calls. A single task is still a one-item array. diff --git a/packages/cli/src/modules/agents/entities/agent-task-snapshot.entity.ts b/packages/cli/src/modules/agents/entities/agent-task-snapshot.entity.ts index 7176720a930..5c921bcd3a9 100644 --- a/packages/cli/src/modules/agents/entities/agent-task-snapshot.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-task-snapshot.entity.ts @@ -43,7 +43,16 @@ export class AgentTaskSnapshot extends WithTimestamps { @Column({ type: 'varchar', length: AGENT_TASK_CRON_EXPRESSION_MAX_LENGTH, - comment: 'Cron schedule evaluated using the instance timezone', + comment: 'Cron schedule evaluated in the timezone of this task', }) cronExpression: string; + + // Same shape as `scheduled_job.timezone`. + @Column({ + type: 'varchar', + length: 64, + nullable: true, + comment: 'IANA timezone the cron is evaluated in; null falls back to the instance timezone', + }) + timezone: string | null; } diff --git a/packages/cli/src/modules/agents/entities/agent-task.entity.ts b/packages/cli/src/modules/agents/entities/agent-task.entity.ts index d6ac1737fd2..ab81e2d7d00 100644 --- a/packages/cli/src/modules/agents/entities/agent-task.entity.ts +++ b/packages/cli/src/modules/agents/entities/agent-task.entity.ts @@ -46,7 +46,16 @@ export class AgentTask extends WithTimestamps { @Column({ type: 'varchar', length: AGENT_TASK_CRON_EXPRESSION_MAX_LENGTH, - comment: 'Cron schedule evaluated using the instance timezone', + comment: 'Cron schedule evaluated in the timezone of this task', }) cronExpression: string; + + // Same shape as `scheduled_job.timezone`. + @Column({ + type: 'varchar', + length: 64, + nullable: true, + comment: 'IANA timezone the cron is evaluated in; null falls back to the instance timezone', + }) + timezone: string | null; } diff --git a/packages/cli/src/modules/agents/repositories/agent-task-snapshot.repository.ts b/packages/cli/src/modules/agents/repositories/agent-task-snapshot.repository.ts index afa50eb7484..65fd52fca07 100644 --- a/packages/cli/src/modules/agents/repositories/agent-task-snapshot.repository.ts +++ b/packages/cli/src/modules/agents/repositories/agent-task-snapshot.repository.ts @@ -5,7 +5,7 @@ import { AgentTaskSnapshot } from '../entities/agent-task-snapshot.entity'; type AgentTaskSnapshotData = Pick< AgentTaskSnapshot, - 'versionId' | 'taskId' | 'enabled' | 'name' | 'objective' | 'cronExpression' + 'versionId' | 'taskId' | 'enabled' | 'name' | 'objective' | 'cronExpression' | 'timezone' >; @Service() diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts b/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts index 12deb4c313b..ef978b3a6aa 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-reference.ts @@ -96,6 +96,8 @@ directly on that object — there is no \`value\` wrapper. For example: - skill.delete: Set \`skillId\` to the skill to delete; its config reference is removed. - task.upsert: Set \`task\` to the complete task body. Omit \`taskId\` to create and attach a new scheduled task, or pass it to replace an existing one. \`enabled\` controls the task config reference. + On a replace, an omitted \`timezone\` keeps the zone the task already has; send \`null\` to move it + back to the instance timezone. - task.delete: Set \`taskId\` to the task to delete; its config reference is removed. - customTool.upsert: Set \`code\` to the tool source; it is compiled, validated, stored, and attached. Only \`@n8n/agents\` and \`zod\` imports are available. The default export must be a Tool builder diff --git a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts index 8ee52d718fb..710ac75e007 100644 --- a/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts +++ b/packages/cli/src/modules/mcp/tools/agents/agent-tools.service.ts @@ -1127,6 +1127,7 @@ export class McpAgentToolsService { name: task.name, objective: task.objective, cronExpression: task.cronExpression, + timezone: task.timezone, enabled: task.enabled, })), customTools: Object.entries(version.tools ?? {}).map(([id, tool]) => ({ diff --git a/packages/cli/test/migration/1787057050000-add-timezone-to-agent-tasks.test.ts b/packages/cli/test/migration/1787057050000-add-timezone-to-agent-tasks.test.ts new file mode 100644 index 00000000000..8510d282f45 --- /dev/null +++ b/packages/cli/test/migration/1787057050000-add-timezone-to-agent-tasks.test.ts @@ -0,0 +1,163 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION_NAME = 'AddTimezoneToAgentTasks1787057050000'; + +const TASK_TABLES = ['agent_task_definition', 'agent_task_snapshot'] as const; + +/** + * Both tables are recreated on SQLite to take the new column, so what these + * cases pin is that rows written before the migration survive it — in both + * directions — and land on the instance timezone (null). + */ +describe('AddTimezoneToAgentTasks Migration', () => { + let dataSource: DataSource; + + async function withContext(fn: (context: TestMigrationContext) => Promise): Promise { + const context = createTestMigrationContext(dataSource); + try { + return await fn(context); + } finally { + await context.queryRunner.release(); + } + } + + beforeAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + dataSource = Container.get(DataSource); + }); + + beforeEach(async () => { + await withContext(async (context) => { + await context.queryRunner.clearDatabase(); + }); + await initDbUpToMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + }); + + /** A project + agent + published version carrying one draft task and one snapshot. */ + async function seedTasks(): Promise<{ taskId: string; versionId: string }> { + const projectId = randomUUID(); + const agentId = randomUUID(); + const versionId = randomUUID(); + const taskId = 'task_1'; + const now = new Date(); + + await withContext(async ({ escape, runQuery }) => { + await runQuery( + `INSERT INTO ${escape.tableName('project')} ("id", "name", "type", "createdAt", "updatedAt") + VALUES (:projectId, 'Project', 'personal', :now, :now)`, + { projectId, now }, + ); + await runQuery( + `INSERT INTO ${escape.tableName('agents')} + ("id", "name", "projectId", "integrations", "tools", "skills", "createdAt", "updatedAt") + VALUES (:agentId, 'Agent', :projectId, '[]', '{}', '{}', :now, :now)`, + { agentId, projectId, now }, + ); + await runQuery( + `INSERT INTO ${escape.tableName('agent_history')} ("versionId", "agentId", "author", "createdAt", "updatedAt") + VALUES (:versionId, :agentId, 'Test User', :now, :now)`, + { versionId, agentId, now }, + ); + await runQuery( + `INSERT INTO ${escape.tableName('agent_task_definition')} + ("id", "agentId", "name", "objective", "cronExpression", "createdAt", "updatedAt") + VALUES (:taskId, :agentId, 'Daily summary', 'Summarize', '0 9 * * *', :now, :now)`, + { taskId, agentId, now }, + ); + await runQuery( + `INSERT INTO ${escape.tableName('agent_task_snapshot')} + ("versionId", "taskId", "enabled", "name", "objective", "cronExpression", "createdAt", "updatedAt") + VALUES (:versionId, :taskId, :enabled, 'Daily summary', 'Summarize', '0 9 * * *', :now, :now)`, + { versionId, taskId, enabled: true, now }, + ); + }); + + return { taskId, versionId }; + } + + async function hasTimezoneColumn( + context: TestMigrationContext, + tableName: string, + ): Promise { + if (context.isSqlite) { + const rows: Array<{ name: string }> = await context.queryRunner.query( + `PRAGMA table_info(${context.escape.tableName(tableName)})`, + ); + return rows.some((row) => row.name === 'timezone'); + } + const rows: unknown[] = await context.queryRunner.query( + 'SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = $2', + [`${context.tablePrefix}${tableName}`, 'timezone'], + ); + return rows.length === 1; + } + + it('adds the column to both task tables, leaving existing rows on the instance timezone', async () => { + const { taskId, versionId } = await seedTasks(); + + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + + await withContext(async (context) => { + for (const table of TASK_TABLES) { + expect(await hasTimezoneColumn(context, table)).toBe(true); + } + + const definitions = await context.runQuery< + Array<{ cronExpression: string; timezone: string | null }> + >( + `SELECT "cronExpression", "timezone" FROM ${context.escape.tableName('agent_task_definition')} WHERE "id" = :id`, + { id: taskId }, + ); + expect(definitions).toEqual([{ cronExpression: '0 9 * * *', timezone: null }]); + + const snapshots = await context.runQuery>( + `SELECT "timezone" FROM ${context.escape.tableName('agent_task_snapshot')} WHERE "versionId" = :versionId`, + { versionId }, + ); + expect(snapshots).toEqual([{ timezone: null }]); + }); + }); + + it('reverts by dropping the column and keeping the task rows', async () => { + const { taskId, versionId } = await seedTasks(); + + await runSingleMigration(MIGRATION_NAME); + await undoLastSingleMigration(); + dataSource = Container.get(DataSource); + + await withContext(async (context) => { + for (const table of TASK_TABLES) { + expect(await hasTimezoneColumn(context, table)).toBe(false); + } + + const definitions = await context.runQuery>( + `SELECT "id" FROM ${context.escape.tableName('agent_task_definition')} WHERE "id" = :id`, + { id: taskId }, + ); + expect(definitions).toHaveLength(1); + + const snapshots = await context.runQuery>( + `SELECT "taskId" FROM ${context.escape.tableName('agent_task_snapshot')} WHERE "versionId" = :versionId`, + { versionId }, + ); + expect(snapshots).toHaveLength(1); + }); + }); +}); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index bb1aa1bdd82..721e433192f 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -7847,6 +7847,8 @@ "agents.builder.tasks.schedule.onDay": "on day", "agents.builder.tasks.schedule.minuteLabel": "at minute", "agents.builder.tasks.schedule.cron.placeholder": "0 9 * * *", + "agents.builder.tasks.schedule.in": "in", + "agents.builder.tasks.schedule.timezone.placeholder": "Select timezone", "agents.builder.tasks.schedule.nextOccurrence": "Next run {occurrence}", "agents.builder.tasks.cancel": "Cancel", "agents.builder.tasks.save": "Save schedule", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTaskModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTaskModal.test.ts index d12e38e6185..95732e486aa 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTaskModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTaskModal.test.ts @@ -1,13 +1,14 @@ import { createTestingPinia } from '@pinia/testing'; import { AGENT_TASK_OBJECTIVE_MAX_LENGTH, type AgentTaskDto } from '@n8n/api-types'; import { configure, fireEvent, waitFor } from '@testing-library/vue'; -import { defineComponent, h, onMounted, watch } from 'vue'; +import { defineComponent, h, nextTick, onMounted, watch } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createComponentRenderer } from '@/__tests__/render'; import { mockedStore } from '@/__tests__/utils'; import { MODAL_CONFIRM } from '@/app/constants'; import { useUIStore } from '@/app/stores/ui.store'; +import { useSettingsStore } from '@n8n/stores/settings.store'; import AgentTaskModal from '../components/AgentTaskModal.vue'; import { formatScheduleDateTime } from '../utils/scheduleBuilder'; @@ -31,6 +32,20 @@ vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore: () => rootStoreMock, })); +// Captured before any test installs fake timers: `vi.useFakeTimers()` swaps +// `Intl.DateTimeFormat` for a wrapper that still builds real instances, so a spy +// on the wrapper's prototype is never reached — this one is. +const RealDateTimeFormat = Intl.DateTimeFormat; + +/** Pin the zone `Intl` reports for the machine viewing the modal. */ +function setBrowserTimezone(timeZone: string): void { + const resolved = new RealDateTimeFormat().resolvedOptions(); + vi.spyOn(RealDateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ + ...resolved, + timeZone, + }); +} + const createAgentTaskSpy = vi.fn(); const updateAgentTaskSpy = vi.fn(); const deleteAgentTaskSpy = vi.fn(); @@ -144,7 +159,12 @@ const stubs = { template: '