@sentry/junior-scheduler 0.107.0 → 0.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Owns model-facing schedule intent and materializes it against the trusted
3
+ * server clock into the canonical schedule persisted by the scheduler.
4
+ */
5
+ import { z } from "zod";
6
+ import {
7
+ getFirstRunAtMs,
8
+ getZonedDateTimeParts,
9
+ resolveLocalScheduleAtMs,
10
+ } from "./cadence";
11
+ import type {
12
+ ScheduledLocalTime,
13
+ ScheduledTaskRecurrence,
14
+ ScheduledTaskSchedule,
15
+ } from "./types";
16
+
17
+ const localDateSchema = z
18
+ .string()
19
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Use a local date in YYYY-MM-DD format.");
20
+ const localTimeSchema = z
21
+ .string()
22
+ .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use local time in HH:MM format.");
23
+ const timezoneSchema = z
24
+ .string()
25
+ .min(1)
26
+ .max(80)
27
+ .describe(
28
+ "IANA timezone, for example America/Los_Angeles. Omit or use null for the scheduler default.",
29
+ )
30
+ .nullable()
31
+ .optional();
32
+ const weekdaySchema = z.enum([
33
+ "sunday",
34
+ "monday",
35
+ "tuesday",
36
+ "wednesday",
37
+ "thursday",
38
+ "friday",
39
+ "saturday",
40
+ ]);
41
+
42
+ const oneOffScheduleIntentSchema = z
43
+ .object({
44
+ kind: z.literal("one_off").describe("A schedule that runs once."),
45
+ timezone: timezoneSchema,
46
+ timing: z
47
+ .discriminatedUnion("type", [
48
+ z
49
+ .object({
50
+ type: z
51
+ .literal("after")
52
+ .describe("Run after a relative delay from the server clock."),
53
+ value: z.number().int().positive().max(100_000),
54
+ unit: z.enum(["second", "minute", "hour", "day", "week"]),
55
+ })
56
+ .strict(),
57
+ z
58
+ .object({
59
+ type: z
60
+ .literal("at")
61
+ .describe("Run at an explicit local calendar date and time."),
62
+ date: localDateSchema.describe(
63
+ "Requested local calendar date in YYYY-MM-DD format.",
64
+ ),
65
+ time: localTimeSchema.describe(
66
+ "Requested local wall-clock time in HH:MM format.",
67
+ ),
68
+ })
69
+ .strict(),
70
+ ])
71
+ .describe("Relative delay or explicit local time for the one-off run."),
72
+ })
73
+ .strict();
74
+
75
+ const recurringScheduleIntentSchema = z
76
+ .object({
77
+ kind: z.literal("recurring").describe("A repeating calendar schedule."),
78
+ frequency: z
79
+ .enum(["daily", "weekly", "monthly", "yearly"])
80
+ .describe("Recurring tasks can run at most once per day."),
81
+ interval: z
82
+ .number()
83
+ .int()
84
+ .positive()
85
+ .max(365)
86
+ .describe("Repeat every N frequency units. Defaults to 1.")
87
+ .nullable()
88
+ .optional(),
89
+ time: localTimeSchema.describe(
90
+ "Local wall-clock time for each occurrence in HH:MM format.",
91
+ ),
92
+ weekdays: z
93
+ .array(weekdaySchema)
94
+ .min(1)
95
+ .max(7)
96
+ .describe("Required for weekly schedules.")
97
+ .nullable()
98
+ .optional(),
99
+ day_of_month: z
100
+ .number()
101
+ .int()
102
+ .min(1)
103
+ .max(31)
104
+ .describe("Required for monthly and yearly schedules.")
105
+ .nullable()
106
+ .optional(),
107
+ month: z
108
+ .number()
109
+ .int()
110
+ .min(1)
111
+ .max(12)
112
+ .describe("Required for yearly schedules, where January is 1.")
113
+ .nullable()
114
+ .optional(),
115
+ start_date: localDateSchema
116
+ .describe(
117
+ "Optional local calendar date that anchors the recurrence. Omit to start with the next matching occurrence.",
118
+ )
119
+ .nullable()
120
+ .optional(),
121
+ timezone: timezoneSchema,
122
+ })
123
+ .strict()
124
+ .superRefine((schedule, context) => {
125
+ if (schedule.frequency === "weekly" && schedule.weekdays == null) {
126
+ context.addIssue({
127
+ code: "custom",
128
+ message: "Weekly schedules require weekdays.",
129
+ path: ["weekdays"],
130
+ });
131
+ }
132
+ if (
133
+ (schedule.frequency === "monthly" || schedule.frequency === "yearly") &&
134
+ schedule.day_of_month == null
135
+ ) {
136
+ context.addIssue({
137
+ code: "custom",
138
+ message: `${schedule.frequency} schedules require day_of_month.`,
139
+ path: ["day_of_month"],
140
+ });
141
+ }
142
+ if (schedule.frequency === "yearly" && schedule.month == null) {
143
+ context.addIssue({
144
+ code: "custom",
145
+ message: "Yearly schedules require month.",
146
+ path: ["month"],
147
+ });
148
+ }
149
+ if (schedule.frequency !== "weekly" && schedule.weekdays != null) {
150
+ context.addIssue({
151
+ code: "custom",
152
+ message: "weekdays applies only to weekly schedules.",
153
+ path: ["weekdays"],
154
+ });
155
+ }
156
+ if (
157
+ schedule.frequency !== "monthly" &&
158
+ schedule.frequency !== "yearly" &&
159
+ schedule.day_of_month != null
160
+ ) {
161
+ context.addIssue({
162
+ code: "custom",
163
+ message: "day_of_month applies only to monthly or yearly schedules.",
164
+ path: ["day_of_month"],
165
+ });
166
+ }
167
+ if (schedule.frequency !== "yearly" && schedule.month != null) {
168
+ context.addIssue({
169
+ code: "custom",
170
+ message: "month applies only to yearly schedules.",
171
+ path: ["month"],
172
+ });
173
+ }
174
+ });
175
+
176
+ export const scheduleIntentSchema = z.union([
177
+ oneOffScheduleIntentSchema,
178
+ recurringScheduleIntentSchema,
179
+ ]);
180
+ export type ScheduleIntent = z.infer<typeof scheduleIntentSchema>;
181
+
182
+ const WEEKDAY_INDEX = {
183
+ sunday: 0,
184
+ monday: 1,
185
+ tuesday: 2,
186
+ wednesday: 3,
187
+ thursday: 4,
188
+ friday: 5,
189
+ saturday: 6,
190
+ } as const;
191
+
192
+ const DURATION_MS = {
193
+ second: 1000,
194
+ minute: 60 * 1000,
195
+ hour: 60 * 60 * 1000,
196
+ day: 24 * 60 * 60 * 1000,
197
+ week: 7 * 24 * 60 * 60 * 1000,
198
+ } as const;
199
+
200
+ function parseLocalTime(value: string): ScheduledLocalTime {
201
+ const [hour, minute] = value.split(":").map(Number);
202
+ return { hour: hour!, minute: minute! };
203
+ }
204
+
205
+ function localDateAt(timestampMs: number, timezone: string): string {
206
+ const parts = getZonedDateTimeParts(timestampMs, timezone);
207
+ return [parts.year, parts.month, parts.day]
208
+ .map((value, index) =>
209
+ index === 0
210
+ ? String(value).padStart(4, "0")
211
+ : String(value).padStart(2, "0"),
212
+ )
213
+ .join("-");
214
+ }
215
+
216
+ function isValidTimeZone(timezone: string): boolean {
217
+ try {
218
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ function plural(value: number, unit: string): string {
226
+ return value === 1 ? unit : `${unit}s`;
227
+ }
228
+
229
+ function formatWeekdays(weekdays: readonly string[]): string {
230
+ const labels = weekdays.map(
231
+ (weekday) => `${weekday[0]!.toUpperCase()}${weekday.slice(1)}`,
232
+ );
233
+ if (labels.length < 2) {
234
+ return labels[0] ?? "";
235
+ }
236
+ return `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`;
237
+ }
238
+
239
+ function recurringDescription(
240
+ schedule: z.infer<typeof recurringScheduleIntentSchema>,
241
+ timezone: string,
242
+ ): string {
243
+ const interval = schedule.interval ?? 1;
244
+ const cadenceUnit = {
245
+ daily: "day",
246
+ weekly: "week",
247
+ monthly: "month",
248
+ yearly: "year",
249
+ }[schedule.frequency];
250
+ const cadence = interval === 1 ? cadenceUnit : `${interval} ${cadenceUnit}s`;
251
+ let detail = "";
252
+ if (schedule.frequency === "weekly") {
253
+ detail = ` on ${formatWeekdays(schedule.weekdays!)}`;
254
+ } else if (schedule.frequency === "monthly") {
255
+ detail = ` on day ${schedule.day_of_month}`;
256
+ } else if (schedule.frequency === "yearly") {
257
+ detail = ` on ${String(schedule.month).padStart(2, "0")}-${String(
258
+ schedule.day_of_month,
259
+ ).padStart(2, "0")}`;
260
+ }
261
+ return `Every ${cadence}${detail} at ${schedule.time} (${timezone})`;
262
+ }
263
+
264
+ export interface CompiledScheduleIntent {
265
+ nextRunAtMs: number;
266
+ schedule: ScheduledTaskSchedule;
267
+ }
268
+
269
+ export class ScheduleIntentError extends Error {}
270
+
271
+ /** Materialize model-interpreted schedule intent against the trusted server clock. */
272
+ export function compileScheduleIntent(args: {
273
+ defaultTimezone: string;
274
+ intent: ScheduleIntent;
275
+ nowMs: number;
276
+ }): CompiledScheduleIntent {
277
+ const timezone = args.intent.timezone ?? args.defaultTimezone;
278
+ if (!isValidTimeZone(timezone)) {
279
+ throw new ScheduleIntentError("timezone must be a valid IANA time zone.");
280
+ }
281
+
282
+ if (args.intent.kind === "one_off") {
283
+ if (args.intent.timing.type === "after") {
284
+ const nextRunAtMs =
285
+ args.nowMs +
286
+ args.intent.timing.value * DURATION_MS[args.intent.timing.unit];
287
+ return {
288
+ nextRunAtMs,
289
+ schedule: {
290
+ description: `In ${args.intent.timing.value} ${plural(
291
+ args.intent.timing.value,
292
+ args.intent.timing.unit,
293
+ )}`,
294
+ kind: "one_off",
295
+ timezone,
296
+ },
297
+ };
298
+ }
299
+
300
+ const nextRunAtMs = resolveLocalScheduleAtMs({
301
+ date: args.intent.timing.date,
302
+ time: parseLocalTime(args.intent.timing.time),
303
+ timezone,
304
+ });
305
+ if (nextRunAtMs === undefined) {
306
+ throw new ScheduleIntentError(
307
+ "The requested local date or time does not exist in that timezone.",
308
+ );
309
+ }
310
+ if (nextRunAtMs <= args.nowMs) {
311
+ throw new ScheduleIntentError(
312
+ "The requested one-off schedule must be in the future.",
313
+ );
314
+ }
315
+ return {
316
+ nextRunAtMs,
317
+ schedule: {
318
+ description: `Once on ${args.intent.timing.date} at ${args.intent.timing.time} (${timezone})`,
319
+ kind: "one_off",
320
+ timezone,
321
+ },
322
+ };
323
+ }
324
+
325
+ const recurrence: ScheduledTaskRecurrence = {
326
+ frequency: args.intent.frequency,
327
+ interval: args.intent.interval ?? 1,
328
+ startDate: args.intent.start_date ?? localDateAt(args.nowMs, timezone),
329
+ time: parseLocalTime(args.intent.time),
330
+ ...(args.intent.frequency === "weekly"
331
+ ? {
332
+ weekdays: [
333
+ ...new Set(
334
+ args.intent.weekdays!.map((weekday) => WEEKDAY_INDEX[weekday]),
335
+ ),
336
+ ].sort((left, right) => left - right),
337
+ }
338
+ : {}),
339
+ ...(args.intent.frequency === "monthly" ||
340
+ args.intent.frequency === "yearly"
341
+ ? { dayOfMonth: args.intent.day_of_month ?? undefined }
342
+ : {}),
343
+ ...(args.intent.frequency === "yearly"
344
+ ? { month: args.intent.month ?? undefined }
345
+ : {}),
346
+ };
347
+ const searchRecurrence = args.intent.start_date
348
+ ? recurrence
349
+ : { ...recurrence, interval: 1 };
350
+ const nextRunAtMs = getFirstRunAtMs({
351
+ afterMs: args.nowMs,
352
+ recurrence: searchRecurrence,
353
+ timezone,
354
+ });
355
+ if (nextRunAtMs === undefined) {
356
+ throw new ScheduleIntentError(
357
+ "The recurring schedule has no valid future occurrence.",
358
+ );
359
+ }
360
+ const materializedRecurrence = args.intent.start_date
361
+ ? recurrence
362
+ : { ...recurrence, startDate: localDateAt(nextRunAtMs, timezone) };
363
+ return {
364
+ nextRunAtMs,
365
+ schedule: {
366
+ description: recurringDescription(args.intent, timezone),
367
+ kind: "recurring",
368
+ recurrence: materializedRecurrence,
369
+ timezone,
370
+ },
371
+ };
372
+ }
package/src/store.ts CHANGED
@@ -148,6 +148,7 @@ const runRecordSchema = z
148
148
 
149
149
  export interface SchedulerStore {
150
150
  claimDueRun(args: { nowMs: number }): Promise<ScheduledRun | undefined>;
151
+ createTask(task: ScheduledTask): Promise<ScheduledTask>;
151
152
  getRun(runId: string): Promise<ScheduledRun | undefined>;
152
153
  getTask(taskId: string): Promise<ScheduledTask | undefined>;
153
154
  listIncompleteRuns(): Promise<ScheduledRun[]>;
@@ -236,6 +237,13 @@ function unique(values: string[]): string[] {
236
237
  return [...new Set(values.filter(Boolean))];
237
238
  }
238
239
 
240
+ const schedulerTaskIndexSchema = z.array(z.string().min(1));
241
+
242
+ /** Parse the persisted scheduler task index without repairing malformed state. */
243
+ function parseStringIndex(value: unknown): string[] {
244
+ return value === undefined ? [] : schedulerTaskIndexSchema.parse(value);
245
+ }
246
+
239
247
  async function withLock<T>(
240
248
  state: PluginState,
241
249
  key: string,
@@ -250,9 +258,7 @@ async function addToIndex(
250
258
  taskId: string,
251
259
  ): Promise<void> {
252
260
  await withLock(state, indexLockKey(key), async () => {
253
- const current = ((await state.get<string[]>(key)) ?? []).filter(
254
- (value): value is string => typeof value === "string",
255
- );
261
+ const current = unique(parseStringIndex(await state.get(key)));
256
262
  await state.set(key, unique([...current, taskId]), SCHEDULER_RECORD_TTL_MS);
257
263
  });
258
264
  }
@@ -263,11 +269,7 @@ async function removeFromIndex(
263
269
  taskId: string,
264
270
  ): Promise<void> {
265
271
  await withLock(state, indexLockKey(key), async () => {
266
- const current = unique(
267
- ((await state.get<string[]>(key)) ?? []).filter(
268
- (value): value is string => typeof value === "string",
269
- ),
270
- );
272
+ const current = unique(parseStringIndex(await state.get(key)));
271
273
  const next = current.filter((value) => value !== taskId);
272
274
  if (next.length === current.length) {
273
275
  return;
@@ -284,10 +286,7 @@ async function getIndex(
284
286
  state: PluginReadState,
285
287
  key: string,
286
288
  ): Promise<string[]> {
287
- const values = (await state.get<string[]>(key)) ?? [];
288
- return unique(
289
- values.filter((value): value is string => typeof value === "string"),
290
- );
289
+ return unique(parseStringIndex(await state.get(key)));
291
290
  }
292
291
 
293
292
  async function clearActiveRun(
@@ -634,6 +633,18 @@ class PluginStateSchedulerStore implements SchedulerStore {
634
633
  this.state = state;
635
634
  }
636
635
 
636
+ async createTask(task: ScheduledTask): Promise<ScheduledTask> {
637
+ const next = requireStoredTask(task);
638
+ return await withLock(this.state, taskLockKey(task.id), async () => {
639
+ const current = await getTaskFromState(this.state, task.id);
640
+ if (current) {
641
+ return current;
642
+ }
643
+ await this.saveTaskRecord(next, undefined);
644
+ return next;
645
+ });
646
+ }
647
+
637
648
  async saveTask(task: ScheduledTask): Promise<void> {
638
649
  const next = requireStoredTask(task);
639
650
  await withLock(this.state, taskLockKey(task.id), async () => {
@@ -1255,6 +1266,18 @@ async function listIncompleteRunsForTasksFromSql(
1255
1266
  class SqlSchedulerStore implements SchedulerStore, SchedulerOperationalStore {
1256
1267
  constructor(private readonly db: SchedulerDb) {}
1257
1268
 
1269
+ async createTask(task: ScheduledTask): Promise<ScheduledTask> {
1270
+ const next = requireStoredTask(task);
1271
+ return await withSqlLock(this.db, taskLockKey(task.id), async (db) => {
1272
+ const current = await getTaskFromSql(db, task.id);
1273
+ if (current) {
1274
+ return current;
1275
+ }
1276
+ await this.saveTaskRecord(db, next, undefined);
1277
+ return next;
1278
+ });
1279
+ }
1280
+
1258
1281
  async saveTask(task: ScheduledTask): Promise<void> {
1259
1282
  const next = requireStoredTask(task);
1260
1283
  await withSqlLock(this.db, taskLockKey(task.id), async (db) => {
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash } from "node:crypto";
2
2
  import {
3
3
  PluginToolInputError,
4
4
  pluginToolResultSchema,
@@ -8,20 +8,18 @@ import {
8
8
  type SlackSource,
9
9
  } from "@sentry/junior-plugin-api";
10
10
  import { z } from "zod";
11
- import { buildCalendarRecurrence, parseScheduleTimestamp } from "./cadence";
12
11
  import { sanitizeScheduledTaskPrincipal } from "./identity";
13
12
  import { type SchedulerStore } from "./store";
14
13
  import type {
15
- ScheduledCalendarFrequency,
16
14
  ScheduledTask,
17
15
  ScheduledTaskConversationAccess,
18
16
  ScheduledTaskPrincipal,
19
- ScheduledTaskRecurrence,
20
17
  ScheduledTaskStatus,
21
18
  } from "./types";
22
19
 
23
20
  export interface SchedulerToolContext {
24
21
  actor?: SlackActor;
22
+ now?: () => number;
25
23
  source?: SlackSource;
26
24
  store: SchedulerStore;
27
25
  userText?: string;
@@ -277,9 +275,29 @@ export function scheduleListToolResult(args: {
277
275
  } as const;
278
276
  }
279
277
 
280
- /** Prefix generated scheduler ids so tool results are distinguishable from provider ids. */
281
- export function buildTaskId(): string {
282
- return `${TASK_ID_PREFIX}_${randomUUID()}`;
278
+ /** Build a retry-stable scheduler id scoped to the creating actor and destination. */
279
+ export function buildTaskId(args: {
280
+ actor: ScheduledTaskPrincipal;
281
+ destination: SlackDestination;
282
+ toolCallId: string | undefined;
283
+ }): string {
284
+ const toolCallId = args.toolCallId?.trim();
285
+ if (!toolCallId) {
286
+ throw new Error("Scheduler task creation requires a tool-call identity.");
287
+ }
288
+ const digest = createHash("sha256")
289
+ .update(
290
+ JSON.stringify({
291
+ actor: args.actor.slackUserId,
292
+ channel: args.destination.channelId,
293
+ operation: toolCallId,
294
+ platform: args.destination.platform,
295
+ team: args.destination.teamId,
296
+ }),
297
+ )
298
+ .digest("hex")
299
+ .slice(0, 32);
300
+ return `${TASK_ID_PREFIX}_${digest}`;
283
301
  }
284
302
 
285
303
  /** Keep concrete scheduler tools coupled to the injected store, not global state. */
@@ -297,143 +315,7 @@ export function normalizeStatus(
297
315
  return undefined;
298
316
  }
299
317
 
300
- function normalizeFrequency(
301
- value: unknown,
302
- ): ScheduledCalendarFrequency | undefined {
303
- if (
304
- value === "daily" ||
305
- value === "weekly" ||
306
- value === "monthly" ||
307
- value === "yearly"
308
- ) {
309
- return value;
310
- }
311
- return undefined;
312
- }
313
-
314
- /** Rebuild recurrence only from validated daily-or-slower schedule requests. */
315
- export function buildRecurrence(args: {
316
- existing?: ScheduledTaskRecurrence;
317
- input: {
318
- recurrence?: unknown;
319
- };
320
- nextRunAtMs: number | undefined;
321
- timezone: string;
322
- }): ScheduledTaskRecurrence | undefined {
323
- if (args.input.recurrence === null) {
324
- return undefined;
325
- }
326
-
327
- const frequency =
328
- normalizeFrequency(args.input.recurrence) ?? args.existing?.frequency;
329
- if (!frequency) {
330
- return undefined;
331
- }
332
- if (!args.nextRunAtMs) {
333
- throwToolInputError("Recurring scheduled tasks require next_run_at.");
334
- }
335
-
336
- try {
337
- return buildCalendarRecurrence({
338
- frequency,
339
- interval: args.existing?.interval,
340
- nextRunAtMs: args.nextRunAtMs,
341
- timezone: args.timezone,
342
- weekdays: frequency === "weekly" ? args.existing?.weekdays : undefined,
343
- });
344
- } catch (error) {
345
- throwToolInputError(
346
- error instanceof RangeError
347
- ? "timezone must be a valid IANA time zone."
348
- : error instanceof Error
349
- ? error.message
350
- : String(error),
351
- );
352
- }
353
- }
354
-
355
- /** Reject recurrence values that would exceed the scheduler cadence policy. */
356
- export function validateRecurringFrequencyLimit(input: {
357
- recurrence?: unknown;
358
- }) {
359
- if (
360
- input.recurrence !== undefined &&
361
- input.recurrence !== null &&
362
- !normalizeFrequency(input.recurrence)
363
- ) {
364
- throwToolInputError(
365
- "Recurring scheduled tasks can run at most once per day.",
366
- );
367
- }
368
- }
369
-
370
- /** Force create-tool callers to explicitly choose one-off versus recurring semantics. */
371
- export function validateCreateScheduleKind(input: {
372
- recurrence?: unknown;
373
- schedule_kind?: unknown;
374
- }) {
375
- if (input.schedule_kind === undefined) {
376
- throwToolInputError("Provide schedule_kind as one_off or recurring.");
377
- }
378
- if (
379
- input.schedule_kind !== "one_off" &&
380
- input.schedule_kind !== "recurring"
381
- ) {
382
- throwToolInputError("schedule_kind must be one_off or recurring.");
383
- }
384
- if (
385
- input.schedule_kind === "one_off" &&
386
- input.recurrence !== undefined &&
387
- input.recurrence !== null
388
- ) {
389
- throwToolInputError("Omit recurrence when schedule_kind is one_off.");
390
- }
391
- if (
392
- input.schedule_kind === "recurring" &&
393
- (input.recurrence === undefined || input.recurrence === null)
394
- ) {
395
- throwToolInputError("Provide recurrence when schedule_kind is recurring.");
396
- }
397
- }
398
-
399
- /** Detect update inputs that affect calendar recurrence materialization. */
400
- export function shouldRebuildRecurrence(input: {
401
- next_run_at?: string;
402
- recurrence?: unknown;
403
- timezone?: string;
404
- }): boolean {
405
- return (
406
- input.next_run_at !== undefined ||
407
- input.recurrence !== undefined ||
408
- input.timezone !== undefined
409
- );
410
- }
411
-
412
318
  /** Centralize scheduler timezone defaulting for all concrete tool entry points. */
413
319
  export function getDefaultScheduleTimezone(): string {
414
320
  return process.env.JUNIOR_TIMEZONE?.trim() || DEFAULT_SCHEDULE_TIMEZONE;
415
321
  }
416
-
417
- /** Validate IANA timezone names before persisting scheduler cadence state. */
418
- export function isValidTimeZone(timezone: string): boolean {
419
- try {
420
- new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
421
- return true;
422
- } catch {
423
- return false;
424
- }
425
- }
426
-
427
- /** Parse model-supplied timestamps without leaking date parser details to tools. */
428
- export function parseNextRunAtMs(
429
- nextRunAtIso: string | undefined,
430
- ): number | undefined {
431
- try {
432
- if (nextRunAtIso) {
433
- return parseScheduleTimestamp(nextRunAtIso);
434
- }
435
- } catch {
436
- return undefined;
437
- }
438
- return undefined;
439
- }