mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
fix(core): Run agent scheduled tasks in the timezone they were authored in (#36226)
This commit is contained in:
Generated
+4
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" {
|
||||
|
||||
Generated
+4
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user