fix(core): Run agent scheduled tasks in the timezone they were authored in (#36226)

This commit is contained in:
Benjamin Schroth
2026-08-18 14:21:46 +00:00
committed by GitHub
parent ce154150e8
commit 3d70ae7fc4
29 changed files with 734 additions and 56 deletions
+4 -2
View File
@@ -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
}
+1
View File
@@ -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
}
@@ -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" {
@@ -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
}
+1
View File
@@ -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" {
+4 -2
View File
@@ -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
}
+1
View File
@@ -122,6 +122,7 @@ erDiagram
varchar_128_ name
TEXT objective
varchar_32_ taskId PK
varchar_64_ timezone
datetime_3_ updatedAt
varchar_36_ versionId PK
}
+3 -1
View File
@@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
@@ -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" {
+3 -1
View File
@@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```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"))
```
</details>
@@ -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
}
+1
View File
@@ -184,6 +184,7 @@ erDiagram
varchar_32_ id PK
varchar_128_ name
TEXT objective
varchar_64_ timezone
datetime_3_ updatedAt
}
"agent_task_run_lock" {
@@ -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<typeof agentTaskSchema>;
@@ -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 = {
@@ -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 });
}
}
}
@@ -36,6 +36,8 @@ function makeTask(overrides: Partial<AgentTask> = {}): 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<AgentTaskSnapshot> = {}): 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<Agent>),
);
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> = {}): 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 }));
@@ -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,
});
}
}
@@ -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<void> {
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(),
};
@@ -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<Awaited<ReturnType<CredentialProvider['list']>>[number] | undefined>;
type CustomToolEntries = Record<string, { code: string; descriptor: ToolDescriptor }>;
type TaskBody = { name: string; objective: string; cronExpression: string };
type TaskBody = AgentTaskConfig;
interface ConfigurationValidationContext {
agentId: string;
@@ -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.
@@ -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.
@@ -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;
}
@@ -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;
}
@@ -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()
@@ -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
@@ -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]) => ({
@@ -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<T>(fn: (context: TestMigrationContext) => Promise<T>): Promise<T> {
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<boolean> {
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<Array<{ timezone: string | null }>>(
`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<Array<{ id: string }>>(
`SELECT "id" FROM ${context.escape.tableName('agent_task_definition')} WHERE "id" = :id`,
{ id: taskId },
);
expect(definitions).toHaveLength(1);
const snapshots = await context.runQuery<Array<{ taskId: string }>>(
`SELECT "taskId" FROM ${context.escape.tableName('agent_task_snapshot')} WHERE "versionId" = :versionId`,
{ versionId },
);
expect(snapshots).toHaveLength(1);
});
});
});
@@ -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",
@@ -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:
'<textarea v-bind="$attrs" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
Select: { props: ['modelValue'], template: '<select v-bind="$attrs"><slot /></select>' },
Select: {
props: ['modelValue'],
emits: ['update:modelValue'],
template:
'<select v-bind="$attrs" :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /></select>',
},
Option: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
};
@@ -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 "<key> <formatted occurrence>" 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 <select> can actually take the value below. Fake timers rule out
// `waitFor`, but the load is pure microtasks.
await Promise.resolve();
await nextTick();
await fireEvent.update(getByTestId('agent-task-timezone'), 'Europe/London');
// 09:00 London on 2 Jan — 09:00 on the 1st has already passed in that zone.
expectNextRun(getByText, '2026-01-02T09:00:00.000Z', 'Europe/London');
await fireEvent.click(getByTestId('agent-task-save'));
expect(updateAgentTaskSpy).toHaveBeenCalledWith(
{},
'p1',
'a1',
'task-9',
expect.objectContaining({ cronExpression: '0 9 * * *', timezone: 'Europe/London' }),
);
});
});
it('runs an existing task and shows a success toast', async () => {
@@ -2,6 +2,7 @@
import {
AGENT_TASK_NAME_MAX_LENGTH,
AGENT_TASK_OBJECTIVE_MAX_LENGTH,
StrictTimeZoneSchema,
type AgentTaskDto,
} from '@n8n/api-types';
import {
@@ -20,7 +21,8 @@ import {
import type { IValidator, Validatable } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, ref, watch } from 'vue';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { computed, onMounted, ref, watch } from 'vue';
import Modal from '@/app/components/Modal.vue';
import { useToast } from '@n8n/composables/useToast';
@@ -65,6 +67,7 @@ const props = defineProps<{
const i18n = useI18n();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const toast = useToast();
const { openAgentConfirmationModal } = useAgentConfirmationModal();
@@ -86,6 +89,13 @@ const hour = ref(DEFAULT_SCHEDULE_PARTS.hour);
const dayOfWeek = ref(DEFAULT_SCHEDULE_PARTS.dayOfWeek);
const dayOfMonth = ref(DEFAULT_SCHEDULE_PARTS.dayOfMonth);
const customCron = ref('');
const timezone = ref(browserTimezone());
const timezoneOptions = ref<Array<{ value: string; label: string }>>([]);
// A task stored without a timezone follows the instance timezone. The selector has
// to show a concrete zone, so remember that the task was on the default and keep
// it there unless the user picks one otherwise saving an unrelated edit would
// silently pin it, and a later instance timezone change would stop applying.
const followsInstanceTimezone = ref(false);
const saving = ref(false);
const errorMessage = ref('');
// Save is always clickable; clicking with invalid data reveals every field's
@@ -97,6 +107,17 @@ const objectiveTouched = ref(false);
// below), so only the custom field's own validator can make this false.
const cronValid = ref(true);
/**
* Zone a new task is authored in the clock the user is actually reading. A host
* that cannot determine its zone reports `Etc/Unknown`, which `Intl` itself then
* refuses, so check against the same schema the API validates with and fall back
* to the instance timezone rather than sending a value that cannot be saved.
*/
function browserTimezone(): string {
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return StrictTimeZoneSchema.safeParse(browserZone).success ? browserZone : rootStore.timezone;
}
const cronExpression = computed(() => {
const freq = frequency.value;
if (freq === 'custom') return customCron.value.trim();
@@ -113,6 +134,11 @@ function applyTask() {
const current = task.value;
name.value = current?.name ?? '';
objective.value = current?.objective ?? '';
// A task saved before schedules carried their own timezone runs in the
// instance timezone, so keep showing that instead of the viewer's zone.
// Absent and null both mean "no zone stored", so treat them alike.
followsInstanceTimezone.value = current !== null && !current.timezone;
timezone.value = current ? (current.timezone ?? rootStore.timezone) : browserTimezone();
const parts = current ? parseCron(current.cronExpression) : { ...DEFAULT_SCHEDULE_PARTS };
if (parts) {
@@ -136,11 +162,11 @@ applyTask();
const initialNameInvalid = isEditing.value && !name.value.trim();
const initialObjectiveInvalid = isEditing.value && !objective.value.trim();
const initialCronInvalid =
isEditing.value && !getNextScheduleOccurrence(cronExpression.value, rootStore.timezone);
isEditing.value && !getNextScheduleOccurrence(cronExpression.value, timezone.value);
const cronValidator: IValidator = {
validate: (value: Validatable) =>
getNextScheduleOccurrence(typeof value === 'string' ? value : '', rootStore.timezone)
getNextScheduleOccurrence(typeof value === 'string' ? value : '', timezone.value)
? false
: { message: i18n.baseText('agents.builder.tasks.validation.cronInvalid' as BaseTextKey) },
};
@@ -197,14 +223,33 @@ function onMinuteInput(value: string) {
minute.value = Number.isFinite(parsed) ? Math.min(59, Math.max(0, Math.trunc(parsed))) : 0;
}
function getUserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? rootStore.timezone;
}
/**
* Same timezone list the workflow settings offer. Kept renderable while it
* loads (and if it fails) by always including the selected zone, since a
* filterable select shows the raw value for an option it doesn't know.
*/
const timezoneSelectOptions = computed(() => {
const options = timezoneOptions.value;
if (options.some((option) => option.value === timezone.value)) return options;
return [{ value: timezone.value, label: timezone.value }, ...options];
});
onMounted(async () => {
try {
const timezones = await settingsStore.getTimezones();
timezoneOptions.value = Object.entries(timezones).map(([value, label]) => ({
value,
label: typeof label === 'string' ? label : value,
}));
} catch {
// Selector keeps the current zone as its only option; saving still works.
}
});
const nextOccurrenceText = computed(() => {
const next = getNextScheduleOccurrence(cronExpression.value, rootStore.timezone);
const next = getNextScheduleOccurrence(cronExpression.value, timezone.value);
if (!next) return '';
return formatScheduleDateTime(next, getUserTimezone());
return formatScheduleDateTime(next, timezone.value);
});
const objectiveError = computed(() => {
@@ -241,6 +286,11 @@ function onCronInput(value: Validatable) {
customCron.value = typeof value === 'string' ? value : '';
}
function onTimezoneChange(value: unknown) {
timezone.value = String(value);
followsInstanceTimezone.value = false;
}
function closeModal() {
uiStore.closeModal(props.modalName);
}
@@ -317,6 +367,7 @@ async function onSave() {
name: name.value.trim(),
objective: objective.value.trim(),
cronExpression: cronExpression.value,
timezone: followsInstanceTimezone.value ? null : timezone.value,
};
try {
@@ -535,6 +586,26 @@ async function onSave() {
@update:model-value="onCronInput"
@validate="cronValid = $event"
/>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tasks.schedule.in') }}
</N8nText>
<N8nSelect
:model-value="timezone"
:class="$style.timezoneSelect"
:placeholder="i18n.baseText('agents.builder.tasks.schedule.timezone.placeholder')"
filterable
:limit-popper-width="true"
data-testid="agent-task-timezone"
@update:model-value="onTimezoneChange"
>
<N8nOption
v-for="option in timezoneSelectOptions"
:key="option.value"
:value="option.value"
:label="option.label"
/>
</N8nSelect>
</div>
<N8nText v-if="nextOccurrenceText" :class="$style.help" size="small">
{{
@@ -651,6 +722,10 @@ async function onSave() {
width: 8rem;
}
.timezoneSelect {
width: 14rem;
}
.help {
color: var(--color--text--tint-1);
}
@@ -129,7 +129,11 @@ export function formatTimeOfDay(hour: number, minute: number): string {
);
}
/** Localized run timestamp (weekday + date + time) in the given timezone. */
/**
* Localized run timestamp (weekday + date + time) in the given timezone. Names
* the zone so the preview stays unambiguous when the schedule's timezone isn't
* the one the reader's own clock is in.
*/
export function formatScheduleDateTime(date: Date, timezone: string): string {
return new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
@@ -138,5 +142,6 @@ export function formatScheduleDateTime(date: Date, timezone: string): string {
month: 'short',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
}).format(date);
}