@sentry/junior-scheduler 0.57.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/LICENSE +201 -0
- package/dist/cadence.d.ts +24 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1655 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/prompt.d.ts +7 -0
- package/dist/schedule-tools.d.ts +25 -0
- package/dist/store.d.ts +49 -0
- package/dist/types.d.ts +86 -0
- package/package.json +38 -0
- package/plugin.yaml +2 -0
- package/src/cadence.ts +501 -0
- package/src/index.ts +27 -0
- package/src/plugin.ts +312 -0
- package/src/prompt.ts +89 -0
- package/src/schedule-tools.ts +640 -0
- package/src/store.ts +819 -0
- package/src/types.ts +110 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1655 @@
|
|
|
1
|
+
// src/plugin.ts
|
|
2
|
+
import {
|
|
3
|
+
defineJuniorPlugin
|
|
4
|
+
} from "@sentry/junior-plugin-api";
|
|
5
|
+
|
|
6
|
+
// src/types.ts
|
|
7
|
+
var SCHEDULED_TASK_SYSTEM_ACTOR = Object.freeze({
|
|
8
|
+
type: "system",
|
|
9
|
+
id: "scheduled-task"
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// src/prompt.ts
|
|
13
|
+
function escapeXml(value) {
|
|
14
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
15
|
+
}
|
|
16
|
+
var EXECUTION_RULES = [
|
|
17
|
+
"- Execute as the scheduled-task system actor; creator metadata is audit context, not an active user identity.",
|
|
18
|
+
"- Complete the task without asking follow-up questions unless access, approval, or required input is missing.",
|
|
19
|
+
"- Use the available tools and skills that are relevant to the task contract.",
|
|
20
|
+
"- Do not create, edit, or discuss scheduling during this run; the stored schedule already fired.",
|
|
21
|
+
"- For reminder tasks, deliver the reminder message now instead of explaining how reminders or delayed posts work.",
|
|
22
|
+
"- If blocked, report the specific missing provider, permission, configuration, or input.",
|
|
23
|
+
"- Keep the final result shaped for the configured destination audience."
|
|
24
|
+
];
|
|
25
|
+
function renderOptionalLine(name, value) {
|
|
26
|
+
return value?.trim() ? [`- ${name}: ${escapeXml(value.trim())}`] : [];
|
|
27
|
+
}
|
|
28
|
+
function buildScheduledTaskRunPrompt(args) {
|
|
29
|
+
const { run, task } = args;
|
|
30
|
+
const destination = task.destination;
|
|
31
|
+
const creator = task.createdBy;
|
|
32
|
+
const executionActor = task.executionActor ?? SCHEDULED_TASK_SYSTEM_ACTOR;
|
|
33
|
+
if (!task.task.text?.trim()) {
|
|
34
|
+
throw new Error("Scheduled task text is required");
|
|
35
|
+
}
|
|
36
|
+
return [
|
|
37
|
+
"<scheduled-task-run>",
|
|
38
|
+
"This is an autonomous scheduled run. Treat the stored task contract as the user request for this turn.",
|
|
39
|
+
"",
|
|
40
|
+
"<scheduled-task>",
|
|
41
|
+
`- id: ${escapeXml(task.id)}`,
|
|
42
|
+
"<task-text>",
|
|
43
|
+
escapeXml(task.task.text),
|
|
44
|
+
"</task-text>",
|
|
45
|
+
"</scheduled-task>",
|
|
46
|
+
"",
|
|
47
|
+
"<run-context>",
|
|
48
|
+
`- run_id: ${escapeXml(run.id)}`,
|
|
49
|
+
`- task_version: ${run.taskVersion}`,
|
|
50
|
+
`- scheduled_for: ${new Date(run.scheduledForMs).toISOString()}`,
|
|
51
|
+
`- running_at: ${new Date(args.nowMs).toISOString()}`,
|
|
52
|
+
`- schedule: ${escapeXml(task.schedule.description)}`,
|
|
53
|
+
`- timezone: ${escapeXml(task.schedule.timezone)}`,
|
|
54
|
+
`- schedule_kind: ${task.schedule.kind}`,
|
|
55
|
+
`- execution_actor_type: ${executionActor.type}`,
|
|
56
|
+
`- execution_actor_id: ${escapeXml(executionActor.id)}`,
|
|
57
|
+
...task.schedule.recurrence ? [
|
|
58
|
+
`- recurrence_frequency: ${task.schedule.recurrence.frequency}`,
|
|
59
|
+
`- recurrence_interval: ${task.schedule.recurrence.interval}`,
|
|
60
|
+
`- recurrence_start_date: ${escapeXml(task.schedule.recurrence.startDate)}`
|
|
61
|
+
] : [],
|
|
62
|
+
`- creator_slack_user_id: ${escapeXml(creator.slackUserId)}`,
|
|
63
|
+
...renderOptionalLine("creator_user_name", creator.userName),
|
|
64
|
+
...renderOptionalLine("creator_full_name", creator.fullName),
|
|
65
|
+
`- destination_platform: ${destination.platform}`,
|
|
66
|
+
`- destination_team_id: ${escapeXml(destination.teamId)}`,
|
|
67
|
+
`- destination_channel_id: ${escapeXml(destination.channelId)}`,
|
|
68
|
+
"</run-context>",
|
|
69
|
+
"",
|
|
70
|
+
"<execution-rules>",
|
|
71
|
+
...EXECUTION_RULES,
|
|
72
|
+
"</execution-rules>",
|
|
73
|
+
"",
|
|
74
|
+
'<current-instruction priority="highest">',
|
|
75
|
+
"Execute the scheduled task now and provide the final result for the configured destination.",
|
|
76
|
+
"</current-instruction>",
|
|
77
|
+
"</scheduled-task-run>"
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/cadence.ts
|
|
82
|
+
function parseScheduleTimestamp(value) {
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/.exec(
|
|
85
|
+
trimmed
|
|
86
|
+
);
|
|
87
|
+
if (!match) {
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
const year = Number(match[1]);
|
|
91
|
+
const month = Number(match[2]);
|
|
92
|
+
const day = Number(match[3]);
|
|
93
|
+
const hour = Number(match[4]);
|
|
94
|
+
const minute = Number(match[5]);
|
|
95
|
+
const second = match[6] ? Number(match[6]) : 0;
|
|
96
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day) || !Number.isInteger(hour) || !Number.isInteger(minute) || !Number.isInteger(second) || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month) || hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
const parsed = Date.parse(trimmed);
|
|
100
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
101
|
+
}
|
|
102
|
+
var FORMATTERS = /* @__PURE__ */ new Map();
|
|
103
|
+
function getFormatter(timezone) {
|
|
104
|
+
const existing = FORMATTERS.get(timezone);
|
|
105
|
+
if (existing) {
|
|
106
|
+
return existing;
|
|
107
|
+
}
|
|
108
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
109
|
+
timeZone: timezone,
|
|
110
|
+
hour12: false,
|
|
111
|
+
year: "numeric",
|
|
112
|
+
month: "2-digit",
|
|
113
|
+
day: "2-digit",
|
|
114
|
+
hour: "2-digit",
|
|
115
|
+
minute: "2-digit",
|
|
116
|
+
second: "2-digit"
|
|
117
|
+
});
|
|
118
|
+
FORMATTERS.set(timezone, formatter);
|
|
119
|
+
return formatter;
|
|
120
|
+
}
|
|
121
|
+
function normalizeHour(hour) {
|
|
122
|
+
return hour === 24 ? 0 : hour;
|
|
123
|
+
}
|
|
124
|
+
function getLocalDateWeekday(date) {
|
|
125
|
+
return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay();
|
|
126
|
+
}
|
|
127
|
+
function getZonedDateTimeParts(timestampMs, timezone) {
|
|
128
|
+
const parts = getFormatter(timezone).formatToParts(new Date(timestampMs));
|
|
129
|
+
const values = new Map(parts.map((part) => [part.type, part.value]));
|
|
130
|
+
const year = Number(values.get("year"));
|
|
131
|
+
const month = Number(values.get("month"));
|
|
132
|
+
const day = Number(values.get("day"));
|
|
133
|
+
const hour = normalizeHour(Number(values.get("hour")));
|
|
134
|
+
const minute = Number(values.get("minute"));
|
|
135
|
+
const second = Number(values.get("second"));
|
|
136
|
+
return {
|
|
137
|
+
year,
|
|
138
|
+
month,
|
|
139
|
+
day,
|
|
140
|
+
hour,
|
|
141
|
+
minute,
|
|
142
|
+
second,
|
|
143
|
+
weekday: getLocalDateWeekday({ year, month, day })
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function getTimeZoneOffsetMs(timestampMs, timezone) {
|
|
147
|
+
const parts = getZonedDateTimeParts(timestampMs, timezone);
|
|
148
|
+
return Date.UTC(
|
|
149
|
+
parts.year,
|
|
150
|
+
parts.month - 1,
|
|
151
|
+
parts.day,
|
|
152
|
+
parts.hour,
|
|
153
|
+
parts.minute,
|
|
154
|
+
parts.second
|
|
155
|
+
) - timestampMs;
|
|
156
|
+
}
|
|
157
|
+
function localDateTimeToTimestampMs(args) {
|
|
158
|
+
const localAsUtcMs = Date.UTC(
|
|
159
|
+
args.date.year,
|
|
160
|
+
args.date.month - 1,
|
|
161
|
+
args.date.day,
|
|
162
|
+
args.time.hour,
|
|
163
|
+
args.time.minute,
|
|
164
|
+
0
|
|
165
|
+
);
|
|
166
|
+
let timestampMs = localAsUtcMs - getTimeZoneOffsetMs(localAsUtcMs, args.timezone);
|
|
167
|
+
for (let index = 0; index < 3; index += 1) {
|
|
168
|
+
const next = localAsUtcMs - getTimeZoneOffsetMs(timestampMs, args.timezone);
|
|
169
|
+
if (next === timestampMs) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
timestampMs = next;
|
|
173
|
+
}
|
|
174
|
+
return timestampMs;
|
|
175
|
+
}
|
|
176
|
+
function compareDate(left, right) {
|
|
177
|
+
return Date.UTC(left.year, left.month - 1, left.day) - Date.UTC(right.year, right.month - 1, right.day);
|
|
178
|
+
}
|
|
179
|
+
function addDays(date, days) {
|
|
180
|
+
const next = new Date(Date.UTC(date.year, date.month - 1, date.day + days));
|
|
181
|
+
return {
|
|
182
|
+
year: next.getUTCFullYear(),
|
|
183
|
+
month: next.getUTCMonth() + 1,
|
|
184
|
+
day: next.getUTCDate()
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function daysInMonth(year, month) {
|
|
188
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
189
|
+
}
|
|
190
|
+
function parseLocalDate(value) {
|
|
191
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
192
|
+
if (!match) {
|
|
193
|
+
return void 0;
|
|
194
|
+
}
|
|
195
|
+
const year = Number(match[1]);
|
|
196
|
+
const month = Number(match[2]);
|
|
197
|
+
const day = Number(match[3]);
|
|
198
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day) || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
|
|
199
|
+
return void 0;
|
|
200
|
+
}
|
|
201
|
+
return { year, month, day };
|
|
202
|
+
}
|
|
203
|
+
function formatLocalDate(date) {
|
|
204
|
+
return [
|
|
205
|
+
String(date.year).padStart(4, "0"),
|
|
206
|
+
String(date.month).padStart(2, "0"),
|
|
207
|
+
String(date.day).padStart(2, "0")
|
|
208
|
+
].join("-");
|
|
209
|
+
}
|
|
210
|
+
function getLocalDate(timestampMs, timezone) {
|
|
211
|
+
const parts = getZonedDateTimeParts(timestampMs, timezone);
|
|
212
|
+
return { year: parts.year, month: parts.month, day: parts.day };
|
|
213
|
+
}
|
|
214
|
+
function normalizeWeekdays(values) {
|
|
215
|
+
return [
|
|
216
|
+
...new Set((values ?? []).filter((value) => value >= 0 && value <= 6))
|
|
217
|
+
].sort((a, b) => a - b);
|
|
218
|
+
}
|
|
219
|
+
function buildCandidate(args) {
|
|
220
|
+
return localDateTimeToTimestampMs({
|
|
221
|
+
date: args.date,
|
|
222
|
+
time: args.recurrence.time,
|
|
223
|
+
timezone: args.timezone
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function getDailyNextRunAtMs(args) {
|
|
227
|
+
const start = parseLocalDate(args.recurrence.startDate);
|
|
228
|
+
if (!start) {
|
|
229
|
+
return void 0;
|
|
230
|
+
}
|
|
231
|
+
let candidateDate = addDays(
|
|
232
|
+
getLocalDate(args.scheduledForMs, args.timezone),
|
|
233
|
+
args.recurrence.interval
|
|
234
|
+
);
|
|
235
|
+
if (compareDate(candidateDate, start) < 0) {
|
|
236
|
+
candidateDate = start;
|
|
237
|
+
}
|
|
238
|
+
let candidate = buildCandidate({
|
|
239
|
+
date: candidateDate,
|
|
240
|
+
recurrence: args.recurrence,
|
|
241
|
+
timezone: args.timezone
|
|
242
|
+
});
|
|
243
|
+
while (candidate <= args.afterMs) {
|
|
244
|
+
candidateDate = addDays(candidateDate, args.recurrence.interval);
|
|
245
|
+
candidate = buildCandidate({
|
|
246
|
+
date: candidateDate,
|
|
247
|
+
recurrence: args.recurrence,
|
|
248
|
+
timezone: args.timezone
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return candidate;
|
|
252
|
+
}
|
|
253
|
+
function getWeeklyNextRunAtMs(args) {
|
|
254
|
+
const start = parseLocalDate(args.recurrence.startDate);
|
|
255
|
+
if (!start) {
|
|
256
|
+
return void 0;
|
|
257
|
+
}
|
|
258
|
+
const weekdays = normalizeWeekdays(args.recurrence.weekdays);
|
|
259
|
+
if (weekdays.length === 0) {
|
|
260
|
+
return void 0;
|
|
261
|
+
}
|
|
262
|
+
let candidateDate = addDays(
|
|
263
|
+
getLocalDate(args.scheduledForMs, args.timezone),
|
|
264
|
+
1
|
|
265
|
+
);
|
|
266
|
+
for (let attempts = 0; attempts < 3660; attempts += 1) {
|
|
267
|
+
const weeksSinceStart = Math.floor(
|
|
268
|
+
(Date.UTC(
|
|
269
|
+
candidateDate.year,
|
|
270
|
+
candidateDate.month - 1,
|
|
271
|
+
candidateDate.day
|
|
272
|
+
) - Date.UTC(start.year, start.month - 1, start.day)) / (7 * 24 * 60 * 60 * 1e3)
|
|
273
|
+
);
|
|
274
|
+
const isInCycle = weeksSinceStart >= 0 && weeksSinceStart % args.recurrence.interval === 0;
|
|
275
|
+
if (isInCycle && weekdays.includes(getLocalDateWeekday(candidateDate))) {
|
|
276
|
+
const candidate = buildCandidate({
|
|
277
|
+
date: candidateDate,
|
|
278
|
+
recurrence: args.recurrence,
|
|
279
|
+
timezone: args.timezone
|
|
280
|
+
});
|
|
281
|
+
if (candidate > args.afterMs) {
|
|
282
|
+
return candidate;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
candidateDate = addDays(candidateDate, 1);
|
|
286
|
+
}
|
|
287
|
+
return void 0;
|
|
288
|
+
}
|
|
289
|
+
function getMonthlyNextRunAtMs(args) {
|
|
290
|
+
const start = parseLocalDate(args.recurrence.startDate);
|
|
291
|
+
const dayOfMonth = args.recurrence.dayOfMonth;
|
|
292
|
+
if (!start || !dayOfMonth) {
|
|
293
|
+
return void 0;
|
|
294
|
+
}
|
|
295
|
+
const scheduledDate = getLocalDate(args.scheduledForMs, args.timezone);
|
|
296
|
+
let monthIndex = scheduledDate.year * 12 + scheduledDate.month - 1;
|
|
297
|
+
const startMonthIndex = start.year * 12 + start.month - 1;
|
|
298
|
+
for (let attempts = 0; attempts < 1200; attempts += 1) {
|
|
299
|
+
monthIndex += args.recurrence.interval;
|
|
300
|
+
if (monthIndex < startMonthIndex) {
|
|
301
|
+
monthIndex = startMonthIndex;
|
|
302
|
+
}
|
|
303
|
+
const year = Math.floor(monthIndex / 12);
|
|
304
|
+
const month = monthIndex % 12 + 1;
|
|
305
|
+
if (dayOfMonth > daysInMonth(year, month)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const candidate = buildCandidate({
|
|
309
|
+
date: { year, month, day: dayOfMonth },
|
|
310
|
+
recurrence: args.recurrence,
|
|
311
|
+
timezone: args.timezone
|
|
312
|
+
});
|
|
313
|
+
if (candidate > args.afterMs) {
|
|
314
|
+
return candidate;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return void 0;
|
|
318
|
+
}
|
|
319
|
+
function getYearlyNextRunAtMs(args) {
|
|
320
|
+
const start = parseLocalDate(args.recurrence.startDate);
|
|
321
|
+
const month = args.recurrence.month;
|
|
322
|
+
const dayOfMonth = args.recurrence.dayOfMonth;
|
|
323
|
+
if (!start || !month || !dayOfMonth) {
|
|
324
|
+
return void 0;
|
|
325
|
+
}
|
|
326
|
+
const scheduledDate = getLocalDate(args.scheduledForMs, args.timezone);
|
|
327
|
+
let year = scheduledDate.year;
|
|
328
|
+
for (let attempts = 0; attempts < 100; attempts += 1) {
|
|
329
|
+
year += args.recurrence.interval;
|
|
330
|
+
if (year < start.year) {
|
|
331
|
+
year = start.year;
|
|
332
|
+
}
|
|
333
|
+
if (dayOfMonth > daysInMonth(year, month)) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const candidate = buildCandidate({
|
|
337
|
+
date: { year, month, day: dayOfMonth },
|
|
338
|
+
recurrence: args.recurrence,
|
|
339
|
+
timezone: args.timezone
|
|
340
|
+
});
|
|
341
|
+
if (candidate > args.afterMs) {
|
|
342
|
+
return candidate;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return void 0;
|
|
346
|
+
}
|
|
347
|
+
function buildCalendarRecurrence(args) {
|
|
348
|
+
const interval = args.interval && args.interval > 0 ? args.interval : 1;
|
|
349
|
+
const parts = getZonedDateTimeParts(args.nextRunAtMs, args.timezone);
|
|
350
|
+
const time = { hour: parts.hour, minute: parts.minute };
|
|
351
|
+
const startDate = formatLocalDate(parts);
|
|
352
|
+
if (args.frequency === "weekly") {
|
|
353
|
+
const weekdays = normalizeWeekdays(args.weekdays);
|
|
354
|
+
return {
|
|
355
|
+
frequency: args.frequency,
|
|
356
|
+
interval,
|
|
357
|
+
startDate,
|
|
358
|
+
time,
|
|
359
|
+
weekdays: weekdays.length > 0 ? weekdays : [parts.weekday]
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
if (args.frequency === "monthly") {
|
|
363
|
+
return {
|
|
364
|
+
dayOfMonth: parts.day,
|
|
365
|
+
frequency: args.frequency,
|
|
366
|
+
interval,
|
|
367
|
+
startDate,
|
|
368
|
+
time
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (args.frequency === "yearly") {
|
|
372
|
+
return {
|
|
373
|
+
dayOfMonth: parts.day,
|
|
374
|
+
frequency: args.frequency,
|
|
375
|
+
interval,
|
|
376
|
+
month: parts.month,
|
|
377
|
+
startDate,
|
|
378
|
+
time
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
frequency: args.frequency,
|
|
383
|
+
interval,
|
|
384
|
+
startDate,
|
|
385
|
+
time
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function getNextRunAtMs(task, scheduledForMs, afterMs = scheduledForMs) {
|
|
389
|
+
if (task.schedule.kind !== "recurring") {
|
|
390
|
+
return void 0;
|
|
391
|
+
}
|
|
392
|
+
const recurrence = task.schedule.recurrence;
|
|
393
|
+
if (!recurrence || !Number.isFinite(recurrence.interval) || recurrence.interval <= 0) {
|
|
394
|
+
return void 0;
|
|
395
|
+
}
|
|
396
|
+
if (recurrence.frequency === "daily") {
|
|
397
|
+
return getDailyNextRunAtMs({
|
|
398
|
+
recurrence,
|
|
399
|
+
timezone: task.schedule.timezone,
|
|
400
|
+
scheduledForMs,
|
|
401
|
+
afterMs
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (recurrence.frequency === "weekly") {
|
|
405
|
+
return getWeeklyNextRunAtMs({
|
|
406
|
+
recurrence,
|
|
407
|
+
timezone: task.schedule.timezone,
|
|
408
|
+
scheduledForMs,
|
|
409
|
+
afterMs
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (recurrence.frequency === "monthly") {
|
|
413
|
+
return getMonthlyNextRunAtMs({
|
|
414
|
+
recurrence,
|
|
415
|
+
timezone: task.schedule.timezone,
|
|
416
|
+
scheduledForMs,
|
|
417
|
+
afterMs
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return getYearlyNextRunAtMs({
|
|
421
|
+
recurrence,
|
|
422
|
+
timezone: task.schedule.timezone,
|
|
423
|
+
scheduledForMs,
|
|
424
|
+
afterMs
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/store.ts
|
|
429
|
+
var SCHEDULER_KEY_PREFIX = "junior:scheduler";
|
|
430
|
+
var SCHEDULER_RECORD_TTL_MS = 5 * 365 * 24 * 60 * 60 * 1e3;
|
|
431
|
+
var SCHEDULED_RUN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
|
|
432
|
+
var CLAIM_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
433
|
+
var PENDING_CLAIM_STALE_MS = 6e4;
|
|
434
|
+
var MISSED_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
435
|
+
var LOCK_TTL_MS = 1e4;
|
|
436
|
+
function taskKey(taskId) {
|
|
437
|
+
return `${SCHEDULER_KEY_PREFIX}:task:${taskId}`;
|
|
438
|
+
}
|
|
439
|
+
function taskLockKey(taskId) {
|
|
440
|
+
return `${taskKey(taskId)}:lock`;
|
|
441
|
+
}
|
|
442
|
+
function runKey(runId) {
|
|
443
|
+
return `${SCHEDULER_KEY_PREFIX}:run:${runId}`;
|
|
444
|
+
}
|
|
445
|
+
function claimKey(taskId, scheduledForMs) {
|
|
446
|
+
return `${SCHEDULER_KEY_PREFIX}:claim:${taskId}:${scheduledForMs}`;
|
|
447
|
+
}
|
|
448
|
+
function activeRunKey(taskId) {
|
|
449
|
+
return `${SCHEDULER_KEY_PREFIX}:active:${taskId}`;
|
|
450
|
+
}
|
|
451
|
+
function globalTaskIndexKey() {
|
|
452
|
+
return `${SCHEDULER_KEY_PREFIX}:tasks`;
|
|
453
|
+
}
|
|
454
|
+
function teamTaskIndexKey(teamId) {
|
|
455
|
+
return `${SCHEDULER_KEY_PREFIX}:team:${teamId}:tasks`;
|
|
456
|
+
}
|
|
457
|
+
function indexLockKey(indexKey) {
|
|
458
|
+
return `${indexKey}:lock`;
|
|
459
|
+
}
|
|
460
|
+
function buildRunId(taskId, scheduledForMs) {
|
|
461
|
+
return `${taskId}:${scheduledForMs}`;
|
|
462
|
+
}
|
|
463
|
+
function unique(values) {
|
|
464
|
+
return [...new Set(values.filter(Boolean))];
|
|
465
|
+
}
|
|
466
|
+
async function withLock(state, key, callback) {
|
|
467
|
+
return await state.withLock(key, LOCK_TTL_MS, callback);
|
|
468
|
+
}
|
|
469
|
+
async function addToIndex(state, key, taskId) {
|
|
470
|
+
await withLock(state, indexLockKey(key), async () => {
|
|
471
|
+
const current = (await state.get(key) ?? []).filter(
|
|
472
|
+
(value) => typeof value === "string"
|
|
473
|
+
);
|
|
474
|
+
await state.set(key, unique([...current, taskId]), SCHEDULER_RECORD_TTL_MS);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
async function removeFromIndex(state, key, taskId) {
|
|
478
|
+
await withLock(state, indexLockKey(key), async () => {
|
|
479
|
+
const current = unique(
|
|
480
|
+
(await state.get(key) ?? []).filter(
|
|
481
|
+
(value) => typeof value === "string"
|
|
482
|
+
)
|
|
483
|
+
);
|
|
484
|
+
const next = current.filter((value) => value !== taskId);
|
|
485
|
+
if (next.length === current.length) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (next.length === 0) {
|
|
489
|
+
await state.delete(key);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
await state.set(key, next, SCHEDULER_RECORD_TTL_MS);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
async function getIndex(state, key) {
|
|
496
|
+
const values = await state.get(key) ?? [];
|
|
497
|
+
return unique(
|
|
498
|
+
values.filter((value) => typeof value === "string")
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
async function clearActiveRun(state, taskId, runId) {
|
|
502
|
+
await withLock(state, indexLockKey(activeRunKey(taskId)), async () => {
|
|
503
|
+
const current = await state.get(activeRunKey(taskId));
|
|
504
|
+
if (current?.runId === runId) {
|
|
505
|
+
await state.delete(activeRunKey(taskId));
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
async function clearStaleActiveRun(state, taskId, nowMs) {
|
|
510
|
+
const active = await state.get(activeRunKey(taskId));
|
|
511
|
+
if (typeof active?.runId !== "string") {
|
|
512
|
+
await state.delete(activeRunKey(taskId));
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
const activeRun = await state.get(runKey(active.runId)) ?? void 0;
|
|
516
|
+
if (!isStaleActiveRun(active, activeRun, nowMs)) {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
await clearActiveRun(state, taskId, active.runId);
|
|
520
|
+
if (typeof active.scheduledForMs === "number") {
|
|
521
|
+
await state.delete(claimKey(taskId, active.scheduledForMs));
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
function isFinishedRun(run) {
|
|
526
|
+
return run.status === "completed" || run.status === "failed" || run.status === "blocked" || run.status === "skipped";
|
|
527
|
+
}
|
|
528
|
+
function isStaleActiveRun(active, run, nowMs) {
|
|
529
|
+
if (run) {
|
|
530
|
+
return isFinishedRun(run) || isStalePendingRun(run, nowMs);
|
|
531
|
+
}
|
|
532
|
+
return typeof active.claimedAtMs === "number" && active.claimedAtMs + PENDING_CLAIM_STALE_MS <= nowMs;
|
|
533
|
+
}
|
|
534
|
+
function isStalePendingRun(run, nowMs) {
|
|
535
|
+
return run?.status === "pending" && run.claimedAtMs + PENDING_CLAIM_STALE_MS <= nowMs;
|
|
536
|
+
}
|
|
537
|
+
function isDueTask(task, nowMs) {
|
|
538
|
+
return task.status === "active" && (typeof task.runNowAtMs === "number" && Number.isFinite(task.runNowAtMs) && task.runNowAtMs <= nowMs || typeof task.nextRunAtMs === "number" && Number.isFinite(task.nextRunAtMs) && task.nextRunAtMs <= nowMs);
|
|
539
|
+
}
|
|
540
|
+
function getDueRunAtMs(task, nowMs) {
|
|
541
|
+
if (typeof task.runNowAtMs === "number" && Number.isFinite(task.runNowAtMs) && task.runNowAtMs <= nowMs) {
|
|
542
|
+
return task.runNowAtMs;
|
|
543
|
+
}
|
|
544
|
+
if (typeof task.nextRunAtMs === "number" && Number.isFinite(task.nextRunAtMs) && task.nextRunAtMs <= nowMs) {
|
|
545
|
+
return task.nextRunAtMs;
|
|
546
|
+
}
|
|
547
|
+
return void 0;
|
|
548
|
+
}
|
|
549
|
+
function buildScheduledRun(args) {
|
|
550
|
+
const idempotencyKey = `${args.task.id}:${args.scheduledForMs}`;
|
|
551
|
+
return {
|
|
552
|
+
id: buildRunId(args.task.id, args.scheduledForMs),
|
|
553
|
+
attempt: 1,
|
|
554
|
+
claimedAtMs: args.claimedAtMs,
|
|
555
|
+
idempotencyKey,
|
|
556
|
+
scheduledForMs: args.scheduledForMs,
|
|
557
|
+
status: "pending",
|
|
558
|
+
taskId: args.task.id,
|
|
559
|
+
taskVersion: args.task.version
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function buildSkippedScheduledRun(args) {
|
|
563
|
+
return {
|
|
564
|
+
...buildScheduledRun({
|
|
565
|
+
claimedAtMs: args.completedAtMs,
|
|
566
|
+
scheduledForMs: args.scheduledForMs,
|
|
567
|
+
task: args.task
|
|
568
|
+
}),
|
|
569
|
+
completedAtMs: args.completedAtMs,
|
|
570
|
+
errorMessage: args.errorMessage,
|
|
571
|
+
status: "skipped"
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function isMissedRunTooOld(args) {
|
|
575
|
+
return args.scheduledForMs + MISSED_RUN_MAX_AGE_MS < args.nowMs;
|
|
576
|
+
}
|
|
577
|
+
function normalizedText(value) {
|
|
578
|
+
return value?.trim().replace(/\s+/g, " ").toLowerCase() ?? "";
|
|
579
|
+
}
|
|
580
|
+
function taskDedupeFingerprint(task) {
|
|
581
|
+
return JSON.stringify({
|
|
582
|
+
destination: task.destination,
|
|
583
|
+
schedule: {
|
|
584
|
+
kind: task.schedule.kind,
|
|
585
|
+
oneOffAtMs: task.schedule.kind === "one_off" ? task.nextRunAtMs : null,
|
|
586
|
+
recurrence: task.schedule.recurrence ? {
|
|
587
|
+
dayOfMonth: task.schedule.recurrence.dayOfMonth ?? null,
|
|
588
|
+
frequency: task.schedule.recurrence.frequency,
|
|
589
|
+
interval: task.schedule.recurrence.interval,
|
|
590
|
+
month: task.schedule.recurrence.month ?? null,
|
|
591
|
+
startDate: task.schedule.recurrence.startDate,
|
|
592
|
+
time: task.schedule.recurrence.time,
|
|
593
|
+
weekdays: [...task.schedule.recurrence.weekdays ?? []].sort()
|
|
594
|
+
} : null,
|
|
595
|
+
timezone: task.schedule.timezone
|
|
596
|
+
},
|
|
597
|
+
task: normalizedText(task.task.text)
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
function isEarlierTask(left, right) {
|
|
601
|
+
return left.createdAtMs < right.createdAtMs || left.createdAtMs === right.createdAtMs && left.id < right.id;
|
|
602
|
+
}
|
|
603
|
+
function canFinishRun(run, startedAtMs) {
|
|
604
|
+
if (run.status === "pending") {
|
|
605
|
+
return startedAtMs === void 0;
|
|
606
|
+
}
|
|
607
|
+
return run.status === "running" && run.startedAtMs === startedAtMs;
|
|
608
|
+
}
|
|
609
|
+
var PluginStateSchedulerStore = class {
|
|
610
|
+
state;
|
|
611
|
+
constructor(state) {
|
|
612
|
+
this.state = state;
|
|
613
|
+
}
|
|
614
|
+
async saveTask(task) {
|
|
615
|
+
await withLock(this.state, taskLockKey(task.id), async () => {
|
|
616
|
+
const current = await this.state.get(taskKey(task.id)) ?? void 0;
|
|
617
|
+
await this.saveTaskRecord(task, current);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
async saveTaskRecord(task, current) {
|
|
621
|
+
if (current?.status === "blocked" && task.status === "active" && typeof task.nextRunAtMs === "number" && Number.isFinite(task.nextRunAtMs)) {
|
|
622
|
+
await this.state.delete(claimKey(task.id, task.nextRunAtMs));
|
|
623
|
+
}
|
|
624
|
+
await this.state.set(taskKey(task.id), task, SCHEDULER_RECORD_TTL_MS);
|
|
625
|
+
if (task.status === "deleted") {
|
|
626
|
+
await removeFromIndex(this.state, globalTaskIndexKey(), task.id);
|
|
627
|
+
await removeFromIndex(
|
|
628
|
+
this.state,
|
|
629
|
+
teamTaskIndexKey(task.destination.teamId),
|
|
630
|
+
task.id
|
|
631
|
+
);
|
|
632
|
+
if (current && current.destination.teamId !== task.destination.teamId) {
|
|
633
|
+
await removeFromIndex(
|
|
634
|
+
this.state,
|
|
635
|
+
teamTaskIndexKey(current.destination.teamId),
|
|
636
|
+
task.id
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
await addToIndex(this.state, globalTaskIndexKey(), task.id);
|
|
642
|
+
await addToIndex(
|
|
643
|
+
this.state,
|
|
644
|
+
teamTaskIndexKey(task.destination.teamId),
|
|
645
|
+
task.id
|
|
646
|
+
);
|
|
647
|
+
if (current && current.destination.teamId !== task.destination.teamId) {
|
|
648
|
+
await removeFromIndex(
|
|
649
|
+
this.state,
|
|
650
|
+
teamTaskIndexKey(current.destination.teamId),
|
|
651
|
+
task.id
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async getTask(taskId) {
|
|
656
|
+
return await this.state.get(taskKey(taskId)) ?? void 0;
|
|
657
|
+
}
|
|
658
|
+
async listTasksForTeam(teamId) {
|
|
659
|
+
const ids = await getIndex(this.state, teamTaskIndexKey(teamId));
|
|
660
|
+
const tasks = await Promise.all(ids.map((id) => this.getTask(id)));
|
|
661
|
+
return tasks.filter((task) => Boolean(task)).filter((task) => task.status !== "deleted").sort((a, b) => a.createdAtMs - b.createdAtMs);
|
|
662
|
+
}
|
|
663
|
+
async claimDueRun(args) {
|
|
664
|
+
const ids = await getIndex(this.state, globalTaskIndexKey());
|
|
665
|
+
for (const id of ids) {
|
|
666
|
+
const task = await this.getTask(id);
|
|
667
|
+
if (!task || !isDueTask(task, args.nowMs)) {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const scheduledForMs = getDueRunAtMs(task, args.nowMs);
|
|
671
|
+
if (scheduledForMs === void 0) {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const runId = buildRunId(task.id, scheduledForMs);
|
|
675
|
+
const tryClaimActiveRun = async () => await this.state.setIfNotExists(
|
|
676
|
+
activeRunKey(task.id),
|
|
677
|
+
{ claimedAtMs: args.nowMs, runId, scheduledForMs },
|
|
678
|
+
CLAIM_TTL_MS
|
|
679
|
+
);
|
|
680
|
+
let activeClaimed = await tryClaimActiveRun();
|
|
681
|
+
if (!activeClaimed) {
|
|
682
|
+
if (await clearStaleActiveRun(this.state, task.id, args.nowMs)) {
|
|
683
|
+
activeClaimed = await tryClaimActiveRun();
|
|
684
|
+
}
|
|
685
|
+
if (!activeClaimed) {
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (isMissedRunTooOld({ nowMs: args.nowMs, scheduledForMs })) {
|
|
690
|
+
await this.skipMissedRun({ nowMs: args.nowMs, scheduledForMs, task });
|
|
691
|
+
await clearActiveRun(this.state, task.id, runId);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const tryClaimScheduledSlot = async () => await this.state.setIfNotExists(
|
|
695
|
+
claimKey(task.id, scheduledForMs),
|
|
696
|
+
{ claimedAtMs: args.nowMs },
|
|
697
|
+
CLAIM_TTL_MS
|
|
698
|
+
);
|
|
699
|
+
let claimed = await tryClaimScheduledSlot();
|
|
700
|
+
if (!claimed) {
|
|
701
|
+
const existingRun = await this.getRun(runId);
|
|
702
|
+
if (isStalePendingRun(existingRun, args.nowMs)) {
|
|
703
|
+
await clearActiveRun(this.state, task.id, runId);
|
|
704
|
+
await this.state.delete(claimKey(task.id, scheduledForMs));
|
|
705
|
+
activeClaimed = await tryClaimActiveRun();
|
|
706
|
+
claimed = activeClaimed ? await tryClaimScheduledSlot() : false;
|
|
707
|
+
}
|
|
708
|
+
if (!claimed) {
|
|
709
|
+
await clearActiveRun(this.state, task.id, runId);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const run = buildScheduledRun({
|
|
714
|
+
claimedAtMs: args.nowMs,
|
|
715
|
+
scheduledForMs,
|
|
716
|
+
task
|
|
717
|
+
});
|
|
718
|
+
await this.state.set(runKey(run.id), run, SCHEDULED_RUN_TTL_MS);
|
|
719
|
+
return run;
|
|
720
|
+
}
|
|
721
|
+
return void 0;
|
|
722
|
+
}
|
|
723
|
+
async skipMissedRun(args) {
|
|
724
|
+
await withLock(this.state, taskLockKey(args.task.id), async () => {
|
|
725
|
+
const current = await this.state.get(taskKey(args.task.id)) ?? void 0;
|
|
726
|
+
if (!current || current.status !== "active" || getDueRunAtMs(current, args.nowMs) !== args.scheduledForMs) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const duplicateOf = await this.findStaleRecoveryCanonicalTask(current);
|
|
730
|
+
const errorMessage = duplicateOf ? `Duplicate stale scheduled task was skipped without dispatch. Canonical task: ${duplicateOf.id}.` : "Scheduled occurrence was more than 24 hours late and was skipped without dispatch.";
|
|
731
|
+
await this.state.set(
|
|
732
|
+
runKey(buildRunId(current.id, args.scheduledForMs)),
|
|
733
|
+
buildSkippedScheduledRun({
|
|
734
|
+
completedAtMs: args.nowMs,
|
|
735
|
+
errorMessage,
|
|
736
|
+
scheduledForMs: args.scheduledForMs,
|
|
737
|
+
task: current
|
|
738
|
+
}),
|
|
739
|
+
SCHEDULED_RUN_TTL_MS
|
|
740
|
+
);
|
|
741
|
+
const isRunNow = current.runNowAtMs === args.scheduledForMs;
|
|
742
|
+
let nextRunAtMs;
|
|
743
|
+
if (!duplicateOf) {
|
|
744
|
+
nextRunAtMs = isRunNow && current.nextRunAtMs !== args.scheduledForMs ? current.nextRunAtMs : current.schedule.kind === "recurring" ? getNextRunAtMs(current, args.scheduledForMs, args.nowMs) : void 0;
|
|
745
|
+
}
|
|
746
|
+
const nextStatus = nextRunAtMs ? "active" : "paused";
|
|
747
|
+
await this.saveTaskRecord(
|
|
748
|
+
{
|
|
749
|
+
...current,
|
|
750
|
+
nextRunAtMs,
|
|
751
|
+
runNowAtMs: isRunNow ? void 0 : current.runNowAtMs,
|
|
752
|
+
status: nextStatus,
|
|
753
|
+
statusReason: nextStatus === "paused" ? errorMessage : void 0,
|
|
754
|
+
updatedAtMs: args.nowMs,
|
|
755
|
+
version: current.version + 1
|
|
756
|
+
},
|
|
757
|
+
current
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
async findStaleRecoveryCanonicalTask(task) {
|
|
762
|
+
const fingerprint = taskDedupeFingerprint(task);
|
|
763
|
+
const ids = await getIndex(
|
|
764
|
+
this.state,
|
|
765
|
+
teamTaskIndexKey(task.destination.teamId)
|
|
766
|
+
);
|
|
767
|
+
const tasks = await Promise.all(
|
|
768
|
+
ids.filter((id) => id !== task.id).map((id) => this.getTask(id))
|
|
769
|
+
);
|
|
770
|
+
return tasks.filter((candidate) => Boolean(candidate)).filter(
|
|
771
|
+
(candidate) => candidate.status === "active" && isEarlierTask(candidate, task) && taskDedupeFingerprint(candidate) === fingerprint
|
|
772
|
+
).sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id)).at(0);
|
|
773
|
+
}
|
|
774
|
+
async getRun(runId) {
|
|
775
|
+
return await this.state.get(runKey(runId)) ?? void 0;
|
|
776
|
+
}
|
|
777
|
+
async listIncompleteRuns() {
|
|
778
|
+
const ids = await getIndex(this.state, globalTaskIndexKey());
|
|
779
|
+
const runs = [];
|
|
780
|
+
for (const taskId of ids) {
|
|
781
|
+
const active = await this.state.get(
|
|
782
|
+
activeRunKey(taskId)
|
|
783
|
+
);
|
|
784
|
+
if (typeof active?.runId !== "string") {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
const run = await this.getRun(active.runId);
|
|
788
|
+
if (run && !isFinishedRun(run)) {
|
|
789
|
+
runs.push(run);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return runs;
|
|
793
|
+
}
|
|
794
|
+
async markRunDispatched(args) {
|
|
795
|
+
return await this.updateRun(
|
|
796
|
+
args.runId,
|
|
797
|
+
(run) => run.status === "pending" && run.claimedAtMs === args.claimedAtMs ? {
|
|
798
|
+
...run,
|
|
799
|
+
dispatchId: args.dispatchId,
|
|
800
|
+
startedAtMs: args.nowMs,
|
|
801
|
+
status: "running"
|
|
802
|
+
} : void 0
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
async markRunCompleted(args) {
|
|
806
|
+
const next = await this.updateRun(
|
|
807
|
+
args.runId,
|
|
808
|
+
(run) => canFinishRun(run, args.startedAtMs) ? {
|
|
809
|
+
...run,
|
|
810
|
+
completedAtMs: args.completedAtMs,
|
|
811
|
+
resultMessageTs: args.resultMessageTs,
|
|
812
|
+
status: "completed"
|
|
813
|
+
} : void 0
|
|
814
|
+
);
|
|
815
|
+
if (next) {
|
|
816
|
+
await clearActiveRun(this.state, next.taskId, next.id);
|
|
817
|
+
}
|
|
818
|
+
return next;
|
|
819
|
+
}
|
|
820
|
+
async markRunFailed(args) {
|
|
821
|
+
const next = await this.updateRun(
|
|
822
|
+
args.runId,
|
|
823
|
+
(run) => canFinishRun(run, args.startedAtMs) ? {
|
|
824
|
+
...run,
|
|
825
|
+
completedAtMs: args.completedAtMs,
|
|
826
|
+
errorMessage: args.errorMessage,
|
|
827
|
+
status: "failed"
|
|
828
|
+
} : void 0
|
|
829
|
+
);
|
|
830
|
+
if (next) {
|
|
831
|
+
await clearActiveRun(this.state, next.taskId, next.id);
|
|
832
|
+
}
|
|
833
|
+
return next;
|
|
834
|
+
}
|
|
835
|
+
async markRunSkipped(args) {
|
|
836
|
+
const next = await this.updateRun(
|
|
837
|
+
args.runId,
|
|
838
|
+
(run) => run.status === "pending" ? {
|
|
839
|
+
...run,
|
|
840
|
+
completedAtMs: args.completedAtMs,
|
|
841
|
+
errorMessage: args.errorMessage,
|
|
842
|
+
status: "skipped"
|
|
843
|
+
} : void 0
|
|
844
|
+
);
|
|
845
|
+
if (next) {
|
|
846
|
+
await clearActiveRun(this.state, next.taskId, next.id);
|
|
847
|
+
}
|
|
848
|
+
return next;
|
|
849
|
+
}
|
|
850
|
+
async markRunBlocked(args) {
|
|
851
|
+
const next = await this.updateRun(
|
|
852
|
+
args.runId,
|
|
853
|
+
(run) => canFinishRun(run, args.startedAtMs) ? {
|
|
854
|
+
...run,
|
|
855
|
+
completedAtMs: args.completedAtMs,
|
|
856
|
+
errorMessage: args.errorMessage,
|
|
857
|
+
status: "blocked"
|
|
858
|
+
} : void 0
|
|
859
|
+
);
|
|
860
|
+
if (next) {
|
|
861
|
+
await clearActiveRun(this.state, next.taskId, next.id);
|
|
862
|
+
}
|
|
863
|
+
return next;
|
|
864
|
+
}
|
|
865
|
+
async updateTaskAfterRun(args) {
|
|
866
|
+
await withLock(this.state, taskLockKey(args.run.taskId), async () => {
|
|
867
|
+
const current = await this.state.get(taskKey(args.run.taskId)) ?? void 0;
|
|
868
|
+
if (!current || current.status === "deleted") {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const isRunNow = current.runNowAtMs === args.run.scheduledForMs;
|
|
872
|
+
if (isRunNow) {
|
|
873
|
+
let nextRunAtMs2 = current.nextRunAtMs;
|
|
874
|
+
if (args.status !== "blocked" && typeof current.nextRunAtMs === "number" && current.nextRunAtMs <= args.run.scheduledForMs) {
|
|
875
|
+
nextRunAtMs2 = getNextRunAtMs(
|
|
876
|
+
current,
|
|
877
|
+
current.nextRunAtMs,
|
|
878
|
+
args.nowMs
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
await this.saveTaskRecord(
|
|
882
|
+
{
|
|
883
|
+
...current,
|
|
884
|
+
lastRunAtMs: args.run.scheduledForMs,
|
|
885
|
+
nextRunAtMs: nextRunAtMs2,
|
|
886
|
+
runNowAtMs: void 0,
|
|
887
|
+
status: args.status === "blocked" ? "blocked" : nextRunAtMs2 ? current.status : "paused",
|
|
888
|
+
statusReason: args.status === "blocked" ? args.errorMessage : void 0,
|
|
889
|
+
updatedAtMs: args.nowMs,
|
|
890
|
+
version: current.version + 1
|
|
891
|
+
},
|
|
892
|
+
current
|
|
893
|
+
);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (current.status !== "active" || current.nextRunAtMs !== args.run.scheduledForMs) {
|
|
897
|
+
await this.saveTaskRecord(
|
|
898
|
+
{
|
|
899
|
+
...current,
|
|
900
|
+
lastRunAtMs: args.run.scheduledForMs,
|
|
901
|
+
updatedAtMs: args.nowMs,
|
|
902
|
+
version: current.version + 1
|
|
903
|
+
},
|
|
904
|
+
current
|
|
905
|
+
);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const nextRunAtMs = args.status === "blocked" ? void 0 : getNextRunAtMs(current, args.run.scheduledForMs, args.nowMs);
|
|
909
|
+
await this.saveTaskRecord(
|
|
910
|
+
{
|
|
911
|
+
...current,
|
|
912
|
+
lastRunAtMs: args.run.scheduledForMs,
|
|
913
|
+
nextRunAtMs,
|
|
914
|
+
status: args.status === "blocked" ? "blocked" : nextRunAtMs ? "active" : "paused",
|
|
915
|
+
statusReason: args.status === "blocked" ? args.errorMessage : void 0,
|
|
916
|
+
updatedAtMs: args.nowMs,
|
|
917
|
+
version: current.version + 1
|
|
918
|
+
},
|
|
919
|
+
current
|
|
920
|
+
);
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
async updateRun(runId, update) {
|
|
924
|
+
return await withLock(this.state, indexLockKey(runKey(runId)), async () => {
|
|
925
|
+
const current = await this.getRun(runId);
|
|
926
|
+
if (!current) {
|
|
927
|
+
return void 0;
|
|
928
|
+
}
|
|
929
|
+
const next = update(current);
|
|
930
|
+
if (!next) {
|
|
931
|
+
return void 0;
|
|
932
|
+
}
|
|
933
|
+
await this.state.set(runKey(runId), next, SCHEDULED_RUN_TTL_MS);
|
|
934
|
+
return next;
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
function createSchedulerStore(state) {
|
|
939
|
+
return new PluginStateSchedulerStore(state);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/schedule-tools.ts
|
|
943
|
+
import { randomUUID } from "crypto";
|
|
944
|
+
import { Type } from "@sinclair/typebox";
|
|
945
|
+
import {
|
|
946
|
+
AgentPluginToolInputError
|
|
947
|
+
} from "@sentry/junior-plugin-api";
|
|
948
|
+
var TASK_ID_PREFIX = "sched";
|
|
949
|
+
var MAX_LISTED_TASKS = 50;
|
|
950
|
+
var DEFAULT_SCHEDULE_TIMEZONE = "America/Los_Angeles";
|
|
951
|
+
var ACTIVE_DESTINATION_GUIDELINE = "Only manage tasks for the active Slack DM or channel; never target an existing thread, another channel, or another user's DM.";
|
|
952
|
+
var ACTIVE_TASK_ID_GUIDELINE = "Use only task IDs returned from this active destination.";
|
|
953
|
+
var RECURRING_GUIDELINE = "Omit recurrence for one-time requests like 'in 1 minute', 'tomorrow', or a specific date; provide recurrence only for requests that explicitly repeat.";
|
|
954
|
+
var recurrenceInputSchema = Type.Union([
|
|
955
|
+
Type.Literal("daily"),
|
|
956
|
+
Type.Literal("weekly"),
|
|
957
|
+
Type.Literal("monthly"),
|
|
958
|
+
Type.Literal("yearly")
|
|
959
|
+
]);
|
|
960
|
+
function throwToolInputError(error) {
|
|
961
|
+
throw new AgentPluginToolInputError(error);
|
|
962
|
+
}
|
|
963
|
+
function requireActiveDestination(context) {
|
|
964
|
+
const channelId = normalizeSlackConversationId(context.channelId);
|
|
965
|
+
if (!channelId) {
|
|
966
|
+
throwToolInputError("No active Slack channel context is available.");
|
|
967
|
+
}
|
|
968
|
+
if (!context.teamId) {
|
|
969
|
+
throwToolInputError("No active Slack workspace context is available.");
|
|
970
|
+
}
|
|
971
|
+
if (!isSlackTeamId(context.teamId)) {
|
|
972
|
+
throwToolInputError("Active Slack workspace context is invalid.");
|
|
973
|
+
}
|
|
974
|
+
return {
|
|
975
|
+
platform: "slack",
|
|
976
|
+
teamId: context.teamId,
|
|
977
|
+
channelId
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function requireRequester(context) {
|
|
981
|
+
const userId = context.requester?.userId;
|
|
982
|
+
if (!userId) {
|
|
983
|
+
throwToolInputError("No active Slack requester context is available.");
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
slackUserId: userId,
|
|
987
|
+
...context.requester?.userName ? { userName: context.requester.userName } : {},
|
|
988
|
+
...context.requester?.fullName ? { fullName: context.requester.fullName } : {}
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
function tool(definition) {
|
|
992
|
+
return definition;
|
|
993
|
+
}
|
|
994
|
+
function normalizeSlackConversationId(value) {
|
|
995
|
+
if (!value) return void 0;
|
|
996
|
+
const trimmed = value.trim();
|
|
997
|
+
if (!trimmed) return void 0;
|
|
998
|
+
if (!trimmed.startsWith("slack:")) return trimmed;
|
|
999
|
+
const parts = trimmed.split(":");
|
|
1000
|
+
return parts[1]?.trim() || void 0;
|
|
1001
|
+
}
|
|
1002
|
+
function isDmChannel(channelId) {
|
|
1003
|
+
return normalizeSlackConversationId(channelId)?.startsWith("D") ?? false;
|
|
1004
|
+
}
|
|
1005
|
+
function isSlackTeamId(value) {
|
|
1006
|
+
return /^T[A-Z0-9]+$/.test(value);
|
|
1007
|
+
}
|
|
1008
|
+
function getConversationAccess(destination) {
|
|
1009
|
+
if (isDmChannel(destination.channelId)) {
|
|
1010
|
+
return { audience: "direct", visibility: "private" };
|
|
1011
|
+
}
|
|
1012
|
+
if (destination.channelId.startsWith("G")) {
|
|
1013
|
+
return { audience: "group", visibility: "private" };
|
|
1014
|
+
}
|
|
1015
|
+
if (destination.channelId.startsWith("C")) {
|
|
1016
|
+
return { audience: "channel", visibility: "unknown" };
|
|
1017
|
+
}
|
|
1018
|
+
return { audience: "channel", visibility: "unknown" };
|
|
1019
|
+
}
|
|
1020
|
+
function getCredentialSubject(args) {
|
|
1021
|
+
if (args.access.audience !== "direct" || args.access.visibility !== "private") {
|
|
1022
|
+
return void 0;
|
|
1023
|
+
}
|
|
1024
|
+
return {
|
|
1025
|
+
type: "user",
|
|
1026
|
+
userId: args.requester.slackUserId,
|
|
1027
|
+
allowedWhen: "private-direct-conversation"
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
function sameDestination(task, destination) {
|
|
1031
|
+
return task.destination.platform === destination.platform && task.destination.teamId === destination.teamId && task.destination.channelId === destination.channelId;
|
|
1032
|
+
}
|
|
1033
|
+
async function getWritableTask(args) {
|
|
1034
|
+
const destination = requireActiveDestination(args.context);
|
|
1035
|
+
const task = await createSchedulerStore(args.context.state).getTask(
|
|
1036
|
+
args.taskId
|
|
1037
|
+
);
|
|
1038
|
+
if (!task || task.status === "deleted") {
|
|
1039
|
+
throwToolInputError(
|
|
1040
|
+
"Scheduled task was not found in the active destination."
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
if (!sameDestination(task, destination)) {
|
|
1044
|
+
throwToolInputError(
|
|
1045
|
+
"Scheduled task can only be managed from the Slack destination where it was created."
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
return task;
|
|
1049
|
+
}
|
|
1050
|
+
function compactTask(task) {
|
|
1051
|
+
return {
|
|
1052
|
+
id: task.id,
|
|
1053
|
+
status: task.status,
|
|
1054
|
+
task: task.task.text,
|
|
1055
|
+
schedule: task.schedule.description,
|
|
1056
|
+
timezone: task.schedule.timezone,
|
|
1057
|
+
recurrence: task.schedule.recurrence ? {
|
|
1058
|
+
frequency: task.schedule.recurrence.frequency,
|
|
1059
|
+
interval: task.schedule.recurrence.interval,
|
|
1060
|
+
start_date: task.schedule.recurrence.startDate,
|
|
1061
|
+
time: task.schedule.recurrence.time,
|
|
1062
|
+
weekdays: task.schedule.recurrence.weekdays,
|
|
1063
|
+
month: task.schedule.recurrence.month,
|
|
1064
|
+
day_of_month: task.schedule.recurrence.dayOfMonth
|
|
1065
|
+
} : null,
|
|
1066
|
+
next_run_at: task.nextRunAtMs ? new Date(task.nextRunAtMs).toISOString() : null,
|
|
1067
|
+
conversation_access: task.conversationAccess ?? null,
|
|
1068
|
+
credential_subject: task.credentialSubject ? {
|
|
1069
|
+
type: task.credentialSubject.type,
|
|
1070
|
+
allowed_when: task.credentialSubject.allowedWhen
|
|
1071
|
+
} : null,
|
|
1072
|
+
last_run_at: task.lastRunAtMs ? new Date(task.lastRunAtMs).toISOString() : null,
|
|
1073
|
+
run_now_at: task.runNowAtMs ? new Date(task.runNowAtMs).toISOString() : null,
|
|
1074
|
+
version: task.version
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
function buildTaskId() {
|
|
1078
|
+
return `${TASK_ID_PREFIX}_${randomUUID()}`;
|
|
1079
|
+
}
|
|
1080
|
+
function normalizeStatus(value) {
|
|
1081
|
+
if (value === "active" || value === "paused" || value === "blocked") {
|
|
1082
|
+
return value;
|
|
1083
|
+
}
|
|
1084
|
+
return void 0;
|
|
1085
|
+
}
|
|
1086
|
+
function normalizeFrequency(value) {
|
|
1087
|
+
if (value === "daily" || value === "weekly" || value === "monthly" || value === "yearly") {
|
|
1088
|
+
return value;
|
|
1089
|
+
}
|
|
1090
|
+
return void 0;
|
|
1091
|
+
}
|
|
1092
|
+
function buildRecurrence(args) {
|
|
1093
|
+
if (args.input.recurrence === null) {
|
|
1094
|
+
return void 0;
|
|
1095
|
+
}
|
|
1096
|
+
const frequency = normalizeFrequency(args.input.recurrence) ?? args.existing?.frequency;
|
|
1097
|
+
if (!frequency) {
|
|
1098
|
+
return void 0;
|
|
1099
|
+
}
|
|
1100
|
+
if (!args.nextRunAtMs) {
|
|
1101
|
+
throwToolInputError("Recurring scheduled tasks require next_run_at.");
|
|
1102
|
+
}
|
|
1103
|
+
try {
|
|
1104
|
+
return buildCalendarRecurrence({
|
|
1105
|
+
frequency,
|
|
1106
|
+
interval: args.existing?.interval,
|
|
1107
|
+
nextRunAtMs: args.nextRunAtMs,
|
|
1108
|
+
timezone: args.timezone,
|
|
1109
|
+
weekdays: frequency === "weekly" ? args.existing?.weekdays : void 0
|
|
1110
|
+
});
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
throwToolInputError(
|
|
1113
|
+
error instanceof RangeError ? "timezone must be a valid IANA time zone." : error instanceof Error ? error.message : String(error)
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
function validateRecurringFrequencyLimit(input) {
|
|
1118
|
+
if (input.recurrence !== void 0 && input.recurrence !== null && !normalizeFrequency(input.recurrence)) {
|
|
1119
|
+
throwToolInputError(
|
|
1120
|
+
"Recurring scheduled tasks can run at most once per day."
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function shouldRebuildRecurrence(input) {
|
|
1125
|
+
return input.next_run_at !== void 0 || input.recurrence !== void 0 || input.timezone !== void 0;
|
|
1126
|
+
}
|
|
1127
|
+
function getDefaultScheduleTimezone() {
|
|
1128
|
+
return process.env.JUNIOR_TIMEZONE?.trim() || DEFAULT_SCHEDULE_TIMEZONE;
|
|
1129
|
+
}
|
|
1130
|
+
function isValidTimeZone(timezone) {
|
|
1131
|
+
try {
|
|
1132
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
|
|
1133
|
+
return true;
|
|
1134
|
+
} catch {
|
|
1135
|
+
return false;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function parseNextRunAtMs(nextRunAtIso) {
|
|
1139
|
+
try {
|
|
1140
|
+
if (nextRunAtIso) {
|
|
1141
|
+
return parseScheduleTimestamp(nextRunAtIso);
|
|
1142
|
+
}
|
|
1143
|
+
} catch {
|
|
1144
|
+
return void 0;
|
|
1145
|
+
}
|
|
1146
|
+
return void 0;
|
|
1147
|
+
}
|
|
1148
|
+
function createSlackScheduleCreateTaskTool(context) {
|
|
1149
|
+
return tool({
|
|
1150
|
+
description: "Create a scheduled Junior task in the active Slack conversation.",
|
|
1151
|
+
promptSnippet: "create future or recurring Junior work here",
|
|
1152
|
+
promptGuidelines: [
|
|
1153
|
+
"Use only when the user explicitly asks Junior to do work later or on a recurring cadence.",
|
|
1154
|
+
ACTIVE_DESTINATION_GUIDELINE,
|
|
1155
|
+
RECURRING_GUIDELINE,
|
|
1156
|
+
"When the user's scheduling intent is clear, create the task immediately without asking for confirmation.",
|
|
1157
|
+
"Ask for confirmation only when the task contract, schedule, or active destination is ambiguous.",
|
|
1158
|
+
"Recurring tasks can run at most once per day; use only daily, weekly, monthly, or yearly recurrence frequencies.",
|
|
1159
|
+
"Provide next_run_at as an exact ISO timestamp computed from the user's requested schedule.",
|
|
1160
|
+
"Provide recurrence only for repeating schedules."
|
|
1161
|
+
],
|
|
1162
|
+
inputSchema: Type.Object({
|
|
1163
|
+
task: Type.String({ minLength: 1, maxLength: 4e3 }),
|
|
1164
|
+
schedule: Type.String({ minLength: 1, maxLength: 300 }),
|
|
1165
|
+
timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
|
|
1166
|
+
next_run_at: Type.Optional(
|
|
1167
|
+
Type.String({
|
|
1168
|
+
minLength: 1,
|
|
1169
|
+
description: "Exact next run time as an ISO timestamp, computed from the user's requested schedule."
|
|
1170
|
+
})
|
|
1171
|
+
),
|
|
1172
|
+
recurrence: Type.Optional(recurrenceInputSchema)
|
|
1173
|
+
}),
|
|
1174
|
+
execute: async (input) => {
|
|
1175
|
+
const destination = requireActiveDestination(context);
|
|
1176
|
+
const requester = requireRequester(context);
|
|
1177
|
+
const nowMs = Date.now();
|
|
1178
|
+
const timezone = input.timezone ?? getDefaultScheduleTimezone();
|
|
1179
|
+
validateRecurringFrequencyLimit(input);
|
|
1180
|
+
if (!isValidTimeZone(timezone)) {
|
|
1181
|
+
throwToolInputError("timezone must be a valid IANA time zone.");
|
|
1182
|
+
}
|
|
1183
|
+
const nextRunAtMs = parseNextRunAtMs(input.next_run_at);
|
|
1184
|
+
if (!nextRunAtMs) {
|
|
1185
|
+
throwToolInputError("Provide next_run_at as a valid ISO timestamp.");
|
|
1186
|
+
}
|
|
1187
|
+
const recurrence = buildRecurrence({
|
|
1188
|
+
input,
|
|
1189
|
+
nextRunAtMs,
|
|
1190
|
+
timezone
|
|
1191
|
+
});
|
|
1192
|
+
const conversationAccess = getConversationAccess(destination);
|
|
1193
|
+
const credentialSubject = getCredentialSubject({
|
|
1194
|
+
access: conversationAccess,
|
|
1195
|
+
requester
|
|
1196
|
+
});
|
|
1197
|
+
const task = {
|
|
1198
|
+
id: buildTaskId(),
|
|
1199
|
+
createdAtMs: nowMs,
|
|
1200
|
+
updatedAtMs: nowMs,
|
|
1201
|
+
createdBy: requester,
|
|
1202
|
+
conversationAccess,
|
|
1203
|
+
...credentialSubject ? { credentialSubject } : {},
|
|
1204
|
+
destination,
|
|
1205
|
+
executionActor: SCHEDULED_TASK_SYSTEM_ACTOR,
|
|
1206
|
+
nextRunAtMs,
|
|
1207
|
+
originalRequest: context.userText,
|
|
1208
|
+
schedule: {
|
|
1209
|
+
description: input.schedule,
|
|
1210
|
+
timezone,
|
|
1211
|
+
kind: recurrence ? "recurring" : "one_off",
|
|
1212
|
+
recurrence
|
|
1213
|
+
},
|
|
1214
|
+
status: "active",
|
|
1215
|
+
task: {
|
|
1216
|
+
text: input.task
|
|
1217
|
+
},
|
|
1218
|
+
version: 1
|
|
1219
|
+
};
|
|
1220
|
+
await createSchedulerStore(context.state).saveTask(task);
|
|
1221
|
+
return {
|
|
1222
|
+
ok: true,
|
|
1223
|
+
task: compactTask(task)
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
function createSlackScheduleListTasksTool(context) {
|
|
1229
|
+
return tool({
|
|
1230
|
+
description: "List scheduled Junior tasks for the active Slack conversation.",
|
|
1231
|
+
promptSnippet: "list schedules for this Slack destination",
|
|
1232
|
+
promptGuidelines: [
|
|
1233
|
+
"Use when the user asks what is scheduled here or needs task IDs before editing, deleting, or running schedules.",
|
|
1234
|
+
ACTIVE_DESTINATION_GUIDELINE
|
|
1235
|
+
],
|
|
1236
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1237
|
+
inputSchema: Type.Object({}),
|
|
1238
|
+
execute: async () => {
|
|
1239
|
+
const destination = requireActiveDestination(context);
|
|
1240
|
+
const tasks = await createSchedulerStore(context.state).listTasksForTeam(
|
|
1241
|
+
destination.teamId
|
|
1242
|
+
);
|
|
1243
|
+
const matching = tasks.filter(
|
|
1244
|
+
(task) => sameDestination(task, destination)
|
|
1245
|
+
);
|
|
1246
|
+
const visible = matching.slice(0, MAX_LISTED_TASKS).map(compactTask);
|
|
1247
|
+
return {
|
|
1248
|
+
ok: true,
|
|
1249
|
+
tasks: visible,
|
|
1250
|
+
truncated: matching.length > visible.length
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
function createSlackScheduleUpdateTaskTool(context) {
|
|
1256
|
+
return tool({
|
|
1257
|
+
description: "Edit, pause, resume, or reschedule a Junior scheduled task.",
|
|
1258
|
+
promptSnippet: "edit/pause/resume one schedule in this Slack destination",
|
|
1259
|
+
promptGuidelines: [
|
|
1260
|
+
ACTIVE_TASK_ID_GUIDELINE,
|
|
1261
|
+
ACTIVE_DESTINATION_GUIDELINE,
|
|
1262
|
+
RECURRING_GUIDELINE,
|
|
1263
|
+
"Do not move scheduled tasks across conversations.",
|
|
1264
|
+
"Provide next_run_at as an exact ISO timestamp when changing the next run.",
|
|
1265
|
+
"Set recurrence to null when converting a recurring task to one-time.",
|
|
1266
|
+
"Set status to active, paused, or blocked when the user asks to resume, pause, or block a task."
|
|
1267
|
+
],
|
|
1268
|
+
inputSchema: Type.Object({
|
|
1269
|
+
task_id: Type.String({ minLength: 1 }),
|
|
1270
|
+
task: Type.Optional(Type.String({ minLength: 1, maxLength: 4e3 })),
|
|
1271
|
+
schedule: Type.Optional(Type.String({ minLength: 1, maxLength: 300 })),
|
|
1272
|
+
timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
|
|
1273
|
+
next_run_at: Type.Optional(Type.String({ minLength: 1 })),
|
|
1274
|
+
recurrence: Type.Optional(
|
|
1275
|
+
Type.Union([recurrenceInputSchema, Type.Null()])
|
|
1276
|
+
),
|
|
1277
|
+
status: Type.Optional(
|
|
1278
|
+
Type.Union([
|
|
1279
|
+
Type.Literal("active"),
|
|
1280
|
+
Type.Literal("paused"),
|
|
1281
|
+
Type.Literal("blocked")
|
|
1282
|
+
])
|
|
1283
|
+
)
|
|
1284
|
+
}),
|
|
1285
|
+
execute: async (input) => {
|
|
1286
|
+
const lookup = await getWritableTask({
|
|
1287
|
+
context,
|
|
1288
|
+
taskId: input.task_id
|
|
1289
|
+
});
|
|
1290
|
+
const timezone = input.timezone ?? lookup.schedule.timezone;
|
|
1291
|
+
validateRecurringFrequencyLimit(input);
|
|
1292
|
+
if (!isValidTimeZone(timezone)) {
|
|
1293
|
+
throwToolInputError("timezone must be a valid IANA time zone.");
|
|
1294
|
+
}
|
|
1295
|
+
const parsedNextRunAtMs = parseNextRunAtMs(input.next_run_at);
|
|
1296
|
+
const nextRunAtMs = input.next_run_at ? parsedNextRunAtMs : lookup.nextRunAtMs;
|
|
1297
|
+
if (input.next_run_at && !nextRunAtMs) {
|
|
1298
|
+
throwToolInputError("Provide next_run_at as a valid ISO timestamp.");
|
|
1299
|
+
}
|
|
1300
|
+
const status = normalizeStatus(input.status);
|
|
1301
|
+
if (input.status && !status) {
|
|
1302
|
+
throwToolInputError("status must be active, paused, or blocked.");
|
|
1303
|
+
}
|
|
1304
|
+
if (status === "active" && !nextRunAtMs) {
|
|
1305
|
+
throwToolInputError(
|
|
1306
|
+
"Active scheduled tasks require next_run_at when no next run is stored."
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
const recurrence = shouldRebuildRecurrence(input) ? buildRecurrence({
|
|
1310
|
+
existing: lookup.schedule.recurrence,
|
|
1311
|
+
input,
|
|
1312
|
+
nextRunAtMs,
|
|
1313
|
+
timezone
|
|
1314
|
+
}) : lookup.schedule.recurrence;
|
|
1315
|
+
const nextStatus = status ?? lookup.status;
|
|
1316
|
+
const next = {
|
|
1317
|
+
...lookup,
|
|
1318
|
+
updatedAtMs: Date.now(),
|
|
1319
|
+
nextRunAtMs,
|
|
1320
|
+
runNowAtMs: nextStatus === "active" ? lookup.runNowAtMs : void 0,
|
|
1321
|
+
status: nextStatus,
|
|
1322
|
+
statusReason: nextStatus === "blocked" ? lookup.statusReason : void 0,
|
|
1323
|
+
schedule: {
|
|
1324
|
+
...lookup.schedule,
|
|
1325
|
+
description: input.schedule ?? lookup.schedule.description,
|
|
1326
|
+
timezone,
|
|
1327
|
+
kind: recurrence ? "recurring" : "one_off",
|
|
1328
|
+
recurrence
|
|
1329
|
+
},
|
|
1330
|
+
task: input.task ? { text: input.task } : lookup.task,
|
|
1331
|
+
version: lookup.version + 1
|
|
1332
|
+
};
|
|
1333
|
+
await createSchedulerStore(context.state).saveTask(next);
|
|
1334
|
+
return {
|
|
1335
|
+
ok: true,
|
|
1336
|
+
task: compactTask(next)
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
function createSlackScheduleDeleteTaskTool(context) {
|
|
1342
|
+
return tool({
|
|
1343
|
+
description: "Delete a Junior scheduled task from the active Slack conversation.",
|
|
1344
|
+
promptSnippet: "delete one schedule from this Slack destination",
|
|
1345
|
+
promptGuidelines: [ACTIVE_TASK_ID_GUIDELINE, ACTIVE_DESTINATION_GUIDELINE],
|
|
1346
|
+
inputSchema: Type.Object({
|
|
1347
|
+
task_id: Type.String({ minLength: 1 })
|
|
1348
|
+
}),
|
|
1349
|
+
execute: async ({ task_id }) => {
|
|
1350
|
+
const lookup = await getWritableTask({ context, taskId: task_id });
|
|
1351
|
+
const next = {
|
|
1352
|
+
...lookup,
|
|
1353
|
+
updatedAtMs: Date.now(),
|
|
1354
|
+
status: "deleted",
|
|
1355
|
+
nextRunAtMs: void 0,
|
|
1356
|
+
runNowAtMs: void 0,
|
|
1357
|
+
version: lookup.version + 1
|
|
1358
|
+
};
|
|
1359
|
+
await createSchedulerStore(context.state).saveTask(next);
|
|
1360
|
+
return {
|
|
1361
|
+
ok: true,
|
|
1362
|
+
task: compactTask(next)
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
function createSlackScheduleRunTaskNowTool(context) {
|
|
1368
|
+
return tool({
|
|
1369
|
+
description: "Queue an active Junior scheduled task to run as soon as possible.",
|
|
1370
|
+
promptSnippet: "run one active schedule now without changing its cadence",
|
|
1371
|
+
promptGuidelines: [
|
|
1372
|
+
ACTIVE_TASK_ID_GUIDELINE,
|
|
1373
|
+
ACTIVE_DESTINATION_GUIDELINE,
|
|
1374
|
+
"Use when the user asks to run an existing scheduled task now; do not rewrite the stored calendar cadence."
|
|
1375
|
+
],
|
|
1376
|
+
inputSchema: Type.Object({
|
|
1377
|
+
task_id: Type.String({ minLength: 1 })
|
|
1378
|
+
}),
|
|
1379
|
+
execute: async ({ task_id }) => {
|
|
1380
|
+
const lookup = await getWritableTask({ context, taskId: task_id });
|
|
1381
|
+
if (lookup.status !== "active") {
|
|
1382
|
+
throwToolInputError(
|
|
1383
|
+
"Scheduled task must be active before it can be run now. Resume the task first if you want it to run."
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
const nowMs = Date.now();
|
|
1387
|
+
const next = {
|
|
1388
|
+
...lookup,
|
|
1389
|
+
updatedAtMs: nowMs,
|
|
1390
|
+
runNowAtMs: nowMs,
|
|
1391
|
+
version: lookup.version + 1
|
|
1392
|
+
};
|
|
1393
|
+
await createSchedulerStore(context.state).saveTask(next);
|
|
1394
|
+
return {
|
|
1395
|
+
ok: true,
|
|
1396
|
+
task: compactTask(next)
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// src/plugin.ts
|
|
1403
|
+
var SCHEDULER_HEARTBEAT_LIMIT = 10;
|
|
1404
|
+
function shouldSkipRun(task, run) {
|
|
1405
|
+
if (task.status === "deleted") {
|
|
1406
|
+
return `Scheduled task ${task.id} was deleted before the run started.`;
|
|
1407
|
+
}
|
|
1408
|
+
if (task.status !== "active") {
|
|
1409
|
+
return `Scheduled task ${task.id} was ${task.status} before the run started.`;
|
|
1410
|
+
}
|
|
1411
|
+
if (task.nextRunAtMs !== run.scheduledForMs && task.runNowAtMs !== run.scheduledForMs) {
|
|
1412
|
+
return `Scheduled task ${task.id} no longer targets ${new Date(run.scheduledForMs).toISOString()}.`;
|
|
1413
|
+
}
|
|
1414
|
+
return void 0;
|
|
1415
|
+
}
|
|
1416
|
+
function createSchedulerToolContext(ctx) {
|
|
1417
|
+
return {
|
|
1418
|
+
channelCapabilities: ctx.channelCapabilities ?? {
|
|
1419
|
+
canAddReactions: false,
|
|
1420
|
+
canCreateCanvas: false,
|
|
1421
|
+
canPostToChannel: false
|
|
1422
|
+
},
|
|
1423
|
+
channelId: ctx.channelId,
|
|
1424
|
+
messageTs: ctx.messageTs,
|
|
1425
|
+
requester: ctx.requester,
|
|
1426
|
+
state: ctx.state,
|
|
1427
|
+
teamId: ctx.teamId,
|
|
1428
|
+
threadTs: ctx.threadTs,
|
|
1429
|
+
userText: ctx.userText
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
async function applyDispatchResult(args) {
|
|
1433
|
+
if (args.dispatch.status === "completed") {
|
|
1434
|
+
const completed = await args.store.markRunCompleted({
|
|
1435
|
+
completedAtMs: args.nowMs,
|
|
1436
|
+
resultMessageTs: args.dispatch.resultMessageTs,
|
|
1437
|
+
runId: args.run.id,
|
|
1438
|
+
startedAtMs: args.run.startedAtMs
|
|
1439
|
+
});
|
|
1440
|
+
if (!completed) {
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
await args.store.updateTaskAfterRun({
|
|
1444
|
+
nowMs: args.nowMs,
|
|
1445
|
+
run: args.run,
|
|
1446
|
+
status: "completed"
|
|
1447
|
+
});
|
|
1448
|
+
return true;
|
|
1449
|
+
}
|
|
1450
|
+
if (args.dispatch.status === "blocked") {
|
|
1451
|
+
const blocked = await args.store.markRunBlocked({
|
|
1452
|
+
completedAtMs: args.nowMs,
|
|
1453
|
+
errorMessage: args.dispatch.errorMessage ?? "Dispatch blocked.",
|
|
1454
|
+
runId: args.run.id,
|
|
1455
|
+
startedAtMs: args.run.startedAtMs
|
|
1456
|
+
});
|
|
1457
|
+
if (!blocked) {
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
await args.store.updateTaskAfterRun({
|
|
1461
|
+
errorMessage: blocked.errorMessage,
|
|
1462
|
+
nowMs: args.nowMs,
|
|
1463
|
+
run: args.run,
|
|
1464
|
+
status: "blocked"
|
|
1465
|
+
});
|
|
1466
|
+
return true;
|
|
1467
|
+
}
|
|
1468
|
+
if (args.dispatch.status === "failed") {
|
|
1469
|
+
const failed = await args.store.markRunFailed({
|
|
1470
|
+
completedAtMs: args.nowMs,
|
|
1471
|
+
errorMessage: args.dispatch.errorMessage ?? "Dispatch failed.",
|
|
1472
|
+
runId: args.run.id,
|
|
1473
|
+
startedAtMs: args.run.startedAtMs
|
|
1474
|
+
});
|
|
1475
|
+
if (!failed) {
|
|
1476
|
+
return false;
|
|
1477
|
+
}
|
|
1478
|
+
await args.store.updateTaskAfterRun({
|
|
1479
|
+
errorMessage: failed.errorMessage,
|
|
1480
|
+
nowMs: args.nowMs,
|
|
1481
|
+
run: args.run,
|
|
1482
|
+
status: "failed"
|
|
1483
|
+
});
|
|
1484
|
+
return true;
|
|
1485
|
+
}
|
|
1486
|
+
return false;
|
|
1487
|
+
}
|
|
1488
|
+
async function blockClaimedRun(args) {
|
|
1489
|
+
const blocked = await args.store.markRunBlocked({
|
|
1490
|
+
completedAtMs: args.nowMs,
|
|
1491
|
+
errorMessage: args.errorMessage,
|
|
1492
|
+
runId: args.run.id
|
|
1493
|
+
});
|
|
1494
|
+
if (!blocked) {
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
await args.store.updateTaskAfterRun({
|
|
1498
|
+
errorMessage: args.errorMessage,
|
|
1499
|
+
nowMs: args.nowMs,
|
|
1500
|
+
run: args.run,
|
|
1501
|
+
status: "blocked"
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
async function failClaimedRun(args) {
|
|
1505
|
+
const failed = await args.store.markRunFailed({
|
|
1506
|
+
completedAtMs: args.nowMs,
|
|
1507
|
+
errorMessage: args.errorMessage,
|
|
1508
|
+
runId: args.run.id,
|
|
1509
|
+
startedAtMs: args.run.startedAtMs
|
|
1510
|
+
});
|
|
1511
|
+
if (!failed) {
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
await args.store.updateTaskAfterRun({
|
|
1515
|
+
errorMessage: args.errorMessage,
|
|
1516
|
+
nowMs: args.nowMs,
|
|
1517
|
+
run: args.run,
|
|
1518
|
+
status: "failed"
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
function createSchedulerPlugin() {
|
|
1522
|
+
return defineJuniorPlugin({
|
|
1523
|
+
name: "scheduler",
|
|
1524
|
+
pluginConfig: {
|
|
1525
|
+
legacyStatePrefixes: ["junior:scheduler"],
|
|
1526
|
+
packages: ["@sentry/junior-scheduler"]
|
|
1527
|
+
},
|
|
1528
|
+
hooks: {
|
|
1529
|
+
tools(ctx) {
|
|
1530
|
+
if (!ctx.channelId || !ctx.teamId || !ctx.requester?.userId) {
|
|
1531
|
+
return {};
|
|
1532
|
+
}
|
|
1533
|
+
const context = createSchedulerToolContext(ctx);
|
|
1534
|
+
return {
|
|
1535
|
+
slackScheduleCreateTask: createSlackScheduleCreateTaskTool(context),
|
|
1536
|
+
slackScheduleListTasks: createSlackScheduleListTasksTool(context),
|
|
1537
|
+
slackScheduleUpdateTask: createSlackScheduleUpdateTaskTool(context),
|
|
1538
|
+
slackScheduleDeleteTask: createSlackScheduleDeleteTaskTool(context),
|
|
1539
|
+
slackScheduleRunTaskNow: createSlackScheduleRunTaskNowTool(context)
|
|
1540
|
+
};
|
|
1541
|
+
},
|
|
1542
|
+
async heartbeat(ctx) {
|
|
1543
|
+
const store = createSchedulerStore(ctx.state);
|
|
1544
|
+
let processedCount = 0;
|
|
1545
|
+
let dispatchCount = 0;
|
|
1546
|
+
for (const run of await store.listIncompleteRuns()) {
|
|
1547
|
+
if (!run.dispatchId) {
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
const dispatch = await ctx.agent.get(run.dispatchId);
|
|
1551
|
+
if (!dispatch) {
|
|
1552
|
+
await failClaimedRun({
|
|
1553
|
+
errorMessage: "Scheduled task dispatch record is missing.",
|
|
1554
|
+
nowMs: ctx.nowMs,
|
|
1555
|
+
run,
|
|
1556
|
+
store
|
|
1557
|
+
});
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (await applyDispatchResult({
|
|
1561
|
+
dispatch,
|
|
1562
|
+
nowMs: ctx.nowMs,
|
|
1563
|
+
run,
|
|
1564
|
+
store
|
|
1565
|
+
})) {
|
|
1566
|
+
processedCount += 1;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
for (let index = processedCount; index < SCHEDULER_HEARTBEAT_LIMIT; index += 1) {
|
|
1570
|
+
const run = await store.claimDueRun({ nowMs: ctx.nowMs });
|
|
1571
|
+
if (!run) {
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
const task = await store.getTask(run.taskId);
|
|
1575
|
+
if (!task) {
|
|
1576
|
+
await store.markRunFailed({
|
|
1577
|
+
completedAtMs: ctx.nowMs,
|
|
1578
|
+
errorMessage: `Scheduled task ${run.taskId} was not found`,
|
|
1579
|
+
runId: run.id
|
|
1580
|
+
});
|
|
1581
|
+
continue;
|
|
1582
|
+
}
|
|
1583
|
+
const skippedReason = shouldSkipRun(task, run);
|
|
1584
|
+
if (skippedReason) {
|
|
1585
|
+
await store.markRunSkipped({
|
|
1586
|
+
completedAtMs: ctx.nowMs,
|
|
1587
|
+
errorMessage: skippedReason,
|
|
1588
|
+
runId: run.id
|
|
1589
|
+
});
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
let prompt;
|
|
1593
|
+
try {
|
|
1594
|
+
prompt = buildScheduledTaskRunPrompt({
|
|
1595
|
+
nowMs: ctx.nowMs,
|
|
1596
|
+
run,
|
|
1597
|
+
task
|
|
1598
|
+
});
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
const errorMessage = error instanceof Error ? `Scheduled task prompt could not be built: ${error.message}` : "Scheduled task prompt could not be built.";
|
|
1601
|
+
await blockClaimedRun({
|
|
1602
|
+
errorMessage,
|
|
1603
|
+
nowMs: ctx.nowMs,
|
|
1604
|
+
run,
|
|
1605
|
+
store
|
|
1606
|
+
});
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
let dispatch;
|
|
1610
|
+
try {
|
|
1611
|
+
dispatch = await ctx.agent.dispatch({
|
|
1612
|
+
idempotencyKey: run.id,
|
|
1613
|
+
...task.credentialSubject ? { credentialSubject: task.credentialSubject } : {},
|
|
1614
|
+
destination: task.destination,
|
|
1615
|
+
input: prompt,
|
|
1616
|
+
metadata: {
|
|
1617
|
+
runId: run.id,
|
|
1618
|
+
taskId: task.id
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
const errorMessage = error instanceof Error ? `Scheduled task dispatch could not be created: ${error.message}` : "Scheduled task dispatch could not be created.";
|
|
1623
|
+
await blockClaimedRun({
|
|
1624
|
+
errorMessage,
|
|
1625
|
+
nowMs: ctx.nowMs,
|
|
1626
|
+
run,
|
|
1627
|
+
store
|
|
1628
|
+
});
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
await store.markRunDispatched({
|
|
1632
|
+
claimedAtMs: run.claimedAtMs,
|
|
1633
|
+
dispatchId: dispatch.id,
|
|
1634
|
+
nowMs: ctx.nowMs,
|
|
1635
|
+
runId: run.id
|
|
1636
|
+
});
|
|
1637
|
+
dispatchCount += 1;
|
|
1638
|
+
}
|
|
1639
|
+
return { dispatchCount };
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
var schedulerPlugin = createSchedulerPlugin;
|
|
1645
|
+
export {
|
|
1646
|
+
buildScheduledTaskRunPrompt,
|
|
1647
|
+
createSchedulerPlugin,
|
|
1648
|
+
createSchedulerStore,
|
|
1649
|
+
createSlackScheduleCreateTaskTool,
|
|
1650
|
+
createSlackScheduleDeleteTaskTool,
|
|
1651
|
+
createSlackScheduleListTasksTool,
|
|
1652
|
+
createSlackScheduleRunTaskNowTool,
|
|
1653
|
+
createSlackScheduleUpdateTaskTool,
|
|
1654
|
+
schedulerPlugin
|
|
1655
|
+
};
|