@sentry/junior-scheduler 0.107.1 → 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.
- package/README.md +10 -1
- package/dist/cadence.d.ts +12 -10
- package/dist/index.js +557 -421
- package/dist/schedule-intent.d.ts +61 -0
- package/dist/store.d.ts +1 -0
- package/dist/tool-support.d.ts +8 -31
- package/dist/tools/create-task.d.ts +23 -5
- package/dist/tools/update-task.d.ts +23 -4
- package/package.json +2 -2
- package/src/cadence.ts +228 -304
- package/src/schedule-intent.ts +372 -0
- package/src/store.ts +35 -12
- package/src/tool-support.ts +25 -143
- package/src/tools/create-task.ts +66 -69
- package/src/tools/update-task.ts +59 -71
|
@@ -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
|
|
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
|
-
|
|
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) => {
|
package/src/tool-support.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
/**
|
|
281
|
-
export function buildTaskId(
|
|
282
|
-
|
|
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
|
-
}
|