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:
'',
},
- Select: { props: ['modelValue'], template: '' },
+ Select: {
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template:
+ '',
+ },
Option: { props: ['value', 'label'], template: '' },
};
@@ -180,6 +200,11 @@ describe('AgentTaskModal', () => {
vi.useRealTimers();
rootStoreMock.timezone = 'UTC';
createTestingPinia({ stubActions: false });
+ // The zone list the schedule's timezone selector offers, trimmed to what
+ // these tests pick from.
+ mockedStore(useSettingsStore).getTimezones = vi
+ .fn()
+ .mockResolvedValue({ 'Asia/Tokyo': 'Asia/Tokyo', 'Europe/London': 'Europe/London' });
uiStore = mockedStore(useUIStore);
uiStore.openModal(MODAL_NAME);
uiStore.closeModal = vi.fn();
@@ -308,26 +333,111 @@ describe('AgentTaskModal', () => {
expect(onToggle).toHaveBeenCalledWith({ id: 'task-9', enabled: false });
});
- it('formats the next run preview in the user timezone', () => {
- vi.useFakeTimers();
- vi.setSystemTime(new Date('2026-01-01T13:00:00.000Z'));
- rootStoreMock.timezone = 'America/New_York';
- const browserTimezone = 'UTC';
- const originalResolvedOptions = new Intl.DateTimeFormat().resolvedOptions();
- vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
- ...originalResolvedOptions,
- timeZone: browserTimezone,
+ describe('schedule timezone', () => {
+ /**
+ * Every case pins "now" so the expected occurrence is exact. Fake timers make
+ * `waitFor` unusable, but nothing here needs it: the async work is all
+ * microtasks (the timezone list load, the save call), so flushing ticks is
+ * enough.
+ */
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T13:00:00.000Z'));
+ rootStoreMock.timezone = 'America/New_York';
});
- const { getByText } = renderModal({ task: makeTask({ cronExpression: '0 9 * * *' }) });
+ /** The next run label reads " " via the i18n mock. */
+ function expectNextRun(
+ getByText: (text: string) => HTMLElement,
+ at: string,
+ inZone: string,
+ ): void {
+ expect(
+ getByText(
+ `agents.builder.tasks.schedule.nextOccurrence ${formatScheduleDateTime(new Date(at), inZone)}`,
+ ),
+ ).toBeInTheDocument();
+ }
- const nextRunInUserTimezone = formatScheduleDateTime(
- new Date('2026-01-01T14:00:00.000Z'),
- browserTimezone,
- );
- expect(
- getByText(`agents.builder.tasks.schedule.nextOccurrence ${nextRunInUserTimezone}`),
- ).toBeInTheDocument();
+ it('previews a new task in the timezone its author is reading', () => {
+ setBrowserTimezone('Asia/Tokyo');
+
+ const { getByText } = renderModal();
+
+ // 13:00 UTC is 22:00 in Tokyo, so the default 09:00 cron fires next morning.
+ expectNextRun(getByText, '2026-01-02T00:00:00.000Z', 'Asia/Tokyo');
+ });
+
+ it("previews an existing task in the task's own timezone", () => {
+ setBrowserTimezone('UTC');
+
+ const { getByText } = renderModal({
+ task: makeTask({ cronExpression: '0 8 * * *', timezone: 'Asia/Tokyo' }),
+ });
+
+ // 08:00 Tokyo on 2 Jan, in Tokyo time — not the viewer's, not the instance's.
+ expectNextRun(getByText, '2026-01-01T23:00:00.000Z', 'Asia/Tokyo');
+ });
+
+ it('previews a task saved without a timezone in the instance timezone', () => {
+ setBrowserTimezone('Asia/Tokyo');
+
+ const { getByText } = renderModal({
+ task: makeTask({ cronExpression: '0 9 * * *', timezone: null }),
+ });
+
+ // Tasks predating per-task timezones really do run on the instance timezone.
+ expectNextRun(getByText, '2026-01-01T14:00:00.000Z', 'America/New_York');
+ });
+
+ it('keeps a task on the instance timezone when the schedule is not touched', async () => {
+ setBrowserTimezone('Asia/Tokyo');
+ updateAgentTaskSpy.mockResolvedValue({});
+
+ const { getByTestId } = renderModal({ task: makeTask({ timezone: null }) });
+
+ await fireEvent.update(getByTestId('agent-task-name-input'), 'Renamed');
+ await fireEvent.click(getByTestId('agent-task-save'));
+
+ // Editing anything else must not pin the task, or a later change to the
+ // instance timezone would stop applying to it.
+ expect(updateAgentTaskSpy).toHaveBeenCalledWith(
+ {},
+ 'p1',
+ 'a1',
+ 'task-9',
+ expect.objectContaining({ timezone: null }),
+ );
+ });
+
+ it('re-previews and saves against a newly picked timezone', async () => {
+ setBrowserTimezone('Asia/Tokyo');
+ updateAgentTaskSpy.mockResolvedValue({});
+
+ const { getByTestId, getByText } = renderModal({
+ task: makeTask({ cronExpression: '0 9 * * *', timezone: 'Asia/Tokyo' }),
+ });
+
+ // Let the awaited timezone list land and re-render its options, so the
+ // stubbed