@sentry/junior-scheduler 0.89.0 → 0.90.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/dist/index.js +198 -158
- package/dist/schedule-tools.d.ts +6 -19
- package/dist/tool-support.d.ts +275 -0
- package/dist/tools/create-task.d.ts +54 -0
- package/dist/tools/delete-task.d.ts +49 -0
- package/dist/tools/list-tasks.d.ts +48 -0
- package/dist/tools/run-task-now.d.ts +49 -0
- package/dist/tools/update-task.d.ts +55 -0
- package/package.json +2 -3
- package/src/plugin.ts +2 -2
- package/src/schedule-tools.ts +6 -692
- package/src/tool-support.ts +471 -0
- package/src/tools/create-task.ts +121 -0
- package/src/tools/delete-task.ts +48 -0
- package/src/tools/list-tasks.ts +42 -0
- package/src/tools/run-task-now.ts +53 -0
- package/src/tools/update-task.ts +121 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
PluginToolInputError,
|
|
4
|
+
pluginToolResultSchema,
|
|
5
|
+
pluginCredentialSubjectSchema,
|
|
6
|
+
sourceSchema,
|
|
7
|
+
type PluginCredentialSubject,
|
|
8
|
+
type SlackDestination,
|
|
9
|
+
type SlackRequester,
|
|
10
|
+
type SlackSource,
|
|
11
|
+
} from "@sentry/junior-plugin-api";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { buildCalendarRecurrence, parseScheduleTimestamp } from "./cadence";
|
|
14
|
+
import { sanitizeScheduledTaskPrincipal } from "./identity";
|
|
15
|
+
import { type SchedulerStore } from "./store";
|
|
16
|
+
import type {
|
|
17
|
+
ScheduledCalendarFrequency,
|
|
18
|
+
ScheduledTask,
|
|
19
|
+
ScheduledTaskConversationAccess,
|
|
20
|
+
ScheduledTaskPrincipal,
|
|
21
|
+
ScheduledTaskRecurrence,
|
|
22
|
+
ScheduledTaskStatus,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
export interface SchedulerToolContext {
|
|
26
|
+
credentialSubject?: PluginCredentialSubject;
|
|
27
|
+
requester?: SlackRequester;
|
|
28
|
+
source?: SlackSource;
|
|
29
|
+
store: SchedulerStore;
|
|
30
|
+
userText?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TASK_ID_PREFIX = "sched";
|
|
34
|
+
export const MAX_LISTED_TASKS = 50;
|
|
35
|
+
const DEFAULT_SCHEDULE_TIMEZONE = "America/Los_Angeles";
|
|
36
|
+
|
|
37
|
+
const compactTaskResultSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
id: z.string(),
|
|
40
|
+
status: z.enum(["active", "paused", "blocked", "deleted"]),
|
|
41
|
+
task: z.string(),
|
|
42
|
+
schedule: z.string(),
|
|
43
|
+
timezone: z.string(),
|
|
44
|
+
recurrence: z.unknown().nullable(),
|
|
45
|
+
next_run_at: z.string().nullable(),
|
|
46
|
+
conversation_access: z.unknown().nullable(),
|
|
47
|
+
credential_subject: z.unknown().nullable(),
|
|
48
|
+
last_run_at: z.string().nullable(),
|
|
49
|
+
run_now_at: z.string().nullable(),
|
|
50
|
+
})
|
|
51
|
+
.strict();
|
|
52
|
+
|
|
53
|
+
const scheduleTaskResultDataSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
ok: z.literal(true),
|
|
56
|
+
task: compactTaskResultSchema,
|
|
57
|
+
})
|
|
58
|
+
.strict();
|
|
59
|
+
|
|
60
|
+
export const scheduleTaskToolResultSchema = pluginToolResultSchema.extend({
|
|
61
|
+
ok: z.literal(true),
|
|
62
|
+
status: z.literal("success"),
|
|
63
|
+
target: z.string(),
|
|
64
|
+
data: scheduleTaskResultDataSchema,
|
|
65
|
+
task: compactTaskResultSchema,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const scheduleListResultDataSchema = z
|
|
69
|
+
.object({
|
|
70
|
+
ok: z.literal(true),
|
|
71
|
+
tasks: z.array(compactTaskResultSchema),
|
|
72
|
+
truncated: z.boolean(),
|
|
73
|
+
})
|
|
74
|
+
.strict();
|
|
75
|
+
|
|
76
|
+
export const scheduleListToolResultSchema = pluginToolResultSchema.extend({
|
|
77
|
+
ok: z.literal(true),
|
|
78
|
+
status: z.literal("success"),
|
|
79
|
+
target: z.string(),
|
|
80
|
+
data: scheduleListResultDataSchema,
|
|
81
|
+
tasks: z.array(compactTaskResultSchema),
|
|
82
|
+
truncated: z.boolean(),
|
|
83
|
+
});
|
|
84
|
+
export type CompactTaskResult = z.output<typeof compactTaskResultSchema>;
|
|
85
|
+
|
|
86
|
+
type SchemaIssue = {
|
|
87
|
+
code: string;
|
|
88
|
+
path: readonly PropertyKey[];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Normalize scheduler validation failures into the plugin tool error contract. */
|
|
92
|
+
export function throwToolInputError(error: string): never {
|
|
93
|
+
throw new PluginToolInputError(error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Require scheduler mutations to stay scoped to the active Slack conversation. */
|
|
97
|
+
export function requireActiveConversation(
|
|
98
|
+
context: SchedulerToolContext,
|
|
99
|
+
): SlackDestination {
|
|
100
|
+
const parsed = sourceSchema.safeParse(context.source);
|
|
101
|
+
if (!parsed.success) {
|
|
102
|
+
const source = context.source as Partial<SlackSource> | undefined;
|
|
103
|
+
const issues = parsed.error.issues as readonly SchemaIssue[];
|
|
104
|
+
if (!source || source.platform !== "slack") {
|
|
105
|
+
throwToolInputError("No active Slack conversation is available.");
|
|
106
|
+
}
|
|
107
|
+
if (issues.some((issue) => issue.code === "unrecognized_keys")) {
|
|
108
|
+
throwToolInputError(
|
|
109
|
+
"Active Slack conversation must not include unknown fields.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (issues.some((issue) => issue.path[0] === "channelId")) {
|
|
113
|
+
throwToolInputError("Active Slack conversation channel is invalid.");
|
|
114
|
+
}
|
|
115
|
+
if (issues.some((issue) => issue.path[0] === "teamId")) {
|
|
116
|
+
throwToolInputError("Active Slack conversation workspace is invalid.");
|
|
117
|
+
}
|
|
118
|
+
throwToolInputError("No active Slack conversation is available.");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (parsed.data.platform !== "slack") {
|
|
122
|
+
throwToolInputError("No active Slack conversation is available.");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
platform: "slack",
|
|
127
|
+
teamId: parsed.data.teamId,
|
|
128
|
+
channelId: parsed.data.channelId,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Require a concrete Slack requester before creating scheduler ownership state. */
|
|
133
|
+
export function requireRequester(
|
|
134
|
+
context: SchedulerToolContext,
|
|
135
|
+
): ScheduledTaskPrincipal {
|
|
136
|
+
if (context.requester?.platform !== "slack") {
|
|
137
|
+
throwToolInputError("No active Slack requester context is available.");
|
|
138
|
+
}
|
|
139
|
+
const userId = context.requester?.userId?.trim();
|
|
140
|
+
if (!userId || userId.toLowerCase() === "unknown") {
|
|
141
|
+
throwToolInputError("No active Slack requester context is available.");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return sanitizeScheduledTaskPrincipal({
|
|
145
|
+
slackUserId: userId,
|
|
146
|
+
...(context.requester?.userName
|
|
147
|
+
? { userName: context.requester.userName }
|
|
148
|
+
: {}),
|
|
149
|
+
...(context.requester?.fullName
|
|
150
|
+
? { fullName: context.requester.fullName }
|
|
151
|
+
: {}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isDmChannel(channelId: string): boolean {
|
|
156
|
+
return channelId.startsWith("D");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Preserve destination visibility so scheduled work can replay with matching access. */
|
|
160
|
+
export function getConversationAccess(
|
|
161
|
+
destination: SlackDestination,
|
|
162
|
+
): ScheduledTaskConversationAccess {
|
|
163
|
+
if (isDmChannel(destination.channelId)) {
|
|
164
|
+
return { audience: "direct", visibility: "private" };
|
|
165
|
+
}
|
|
166
|
+
if (destination.channelId.startsWith("G")) {
|
|
167
|
+
return { audience: "group", visibility: "private" };
|
|
168
|
+
}
|
|
169
|
+
if (destination.channelId.startsWith("C")) {
|
|
170
|
+
return { audience: "channel", visibility: "unknown" };
|
|
171
|
+
}
|
|
172
|
+
return { audience: "channel", visibility: "unknown" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Carry private-DM credential authority only when scheduled work is allowed to reuse it. */
|
|
176
|
+
export function getCredentialSubject(args: {
|
|
177
|
+
access: ScheduledTaskConversationAccess;
|
|
178
|
+
subject: PluginCredentialSubject | undefined;
|
|
179
|
+
}): PluginCredentialSubject | undefined {
|
|
180
|
+
if (
|
|
181
|
+
args.access.audience !== "direct" ||
|
|
182
|
+
args.access.visibility !== "private"
|
|
183
|
+
) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
if (!args.subject) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const subject = pluginCredentialSubjectSchema.safeParse(args.subject);
|
|
190
|
+
if (!subject.success) {
|
|
191
|
+
throwToolInputError("Active Slack credential subject is invalid.");
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
type: subject.data.type,
|
|
195
|
+
userId: subject.data.userId,
|
|
196
|
+
allowedWhen: subject.data.allowedWhen,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Keep scheduler management operations bound to the task's original Slack destination. */
|
|
201
|
+
export function sameDestination(
|
|
202
|
+
task: ScheduledTask,
|
|
203
|
+
destination: SlackDestination,
|
|
204
|
+
): boolean {
|
|
205
|
+
const taskDestination = task.destination;
|
|
206
|
+
return (
|
|
207
|
+
taskDestination.platform === "slack" &&
|
|
208
|
+
taskDestination.teamId === destination.teamId &&
|
|
209
|
+
taskDestination.channelId === destination.channelId
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Look up a mutable task only after enforcing active-conversation ownership. */
|
|
214
|
+
export async function getWritableTask(args: {
|
|
215
|
+
context: SchedulerToolContext;
|
|
216
|
+
taskId: string;
|
|
217
|
+
}): Promise<ScheduledTask> {
|
|
218
|
+
const destination = requireActiveConversation(args.context);
|
|
219
|
+
|
|
220
|
+
const task = await schedulerStore(args.context).getTask(args.taskId);
|
|
221
|
+
if (!task || task.status === "deleted") {
|
|
222
|
+
throwToolInputError(
|
|
223
|
+
"Scheduled task was not found in the active Slack conversation.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!sameDestination(task, destination)) {
|
|
228
|
+
throwToolInputError(
|
|
229
|
+
"Scheduled task can only be managed from the Slack destination where it was created.",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return task;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Project scheduled task state into the stable model-facing result shape. */
|
|
236
|
+
export function compactTask(task: ScheduledTask): CompactTaskResult {
|
|
237
|
+
return compactTaskResultSchema.parse({
|
|
238
|
+
id: task.id,
|
|
239
|
+
status: task.status,
|
|
240
|
+
task: task.task.text,
|
|
241
|
+
schedule: task.schedule.description,
|
|
242
|
+
timezone: task.schedule.timezone,
|
|
243
|
+
recurrence: task.schedule.recurrence
|
|
244
|
+
? {
|
|
245
|
+
frequency: task.schedule.recurrence.frequency,
|
|
246
|
+
interval: task.schedule.recurrence.interval,
|
|
247
|
+
start_date: task.schedule.recurrence.startDate,
|
|
248
|
+
time: task.schedule.recurrence.time,
|
|
249
|
+
weekdays: task.schedule.recurrence.weekdays,
|
|
250
|
+
month: task.schedule.recurrence.month,
|
|
251
|
+
day_of_month: task.schedule.recurrence.dayOfMonth,
|
|
252
|
+
}
|
|
253
|
+
: null,
|
|
254
|
+
next_run_at: task.nextRunAtMs
|
|
255
|
+
? new Date(task.nextRunAtMs).toISOString()
|
|
256
|
+
: null,
|
|
257
|
+
conversation_access: task.conversationAccess ?? null,
|
|
258
|
+
credential_subject: task.credentialSubject
|
|
259
|
+
? {
|
|
260
|
+
type: task.credentialSubject.type,
|
|
261
|
+
allowed_when: task.credentialSubject.allowedWhen,
|
|
262
|
+
}
|
|
263
|
+
: null,
|
|
264
|
+
last_run_at: task.lastRunAtMs
|
|
265
|
+
? new Date(task.lastRunAtMs).toISOString()
|
|
266
|
+
: null,
|
|
267
|
+
run_now_at: task.runNowAtMs
|
|
268
|
+
? new Date(task.runNowAtMs).toISOString()
|
|
269
|
+
: null,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Build the structured result shared by single-task scheduler tools. */
|
|
274
|
+
export function scheduleTaskToolResult(
|
|
275
|
+
target: string,
|
|
276
|
+
task: CompactTaskResult,
|
|
277
|
+
) {
|
|
278
|
+
const data = {
|
|
279
|
+
ok: true,
|
|
280
|
+
task,
|
|
281
|
+
} as const;
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
status: "success",
|
|
285
|
+
target,
|
|
286
|
+
data,
|
|
287
|
+
task,
|
|
288
|
+
} as const;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Build the structured result for listing scheduler tools. */
|
|
292
|
+
export function scheduleListToolResult(args: {
|
|
293
|
+
target: string;
|
|
294
|
+
tasks: CompactTaskResult[];
|
|
295
|
+
truncated: boolean;
|
|
296
|
+
}) {
|
|
297
|
+
const data = {
|
|
298
|
+
ok: true,
|
|
299
|
+
tasks: args.tasks,
|
|
300
|
+
truncated: args.truncated,
|
|
301
|
+
} as const;
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
status: "success",
|
|
305
|
+
target: args.target,
|
|
306
|
+
data,
|
|
307
|
+
tasks: args.tasks,
|
|
308
|
+
truncated: args.truncated,
|
|
309
|
+
} as const;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Prefix generated scheduler ids so tool results are distinguishable from provider ids. */
|
|
313
|
+
export function buildTaskId(): string {
|
|
314
|
+
return `${TASK_ID_PREFIX}_${randomUUID()}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Keep concrete scheduler tools coupled to the injected store, not global state. */
|
|
318
|
+
export function schedulerStore(context: SchedulerToolContext): SchedulerStore {
|
|
319
|
+
return context.store;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Accept only persisted scheduler statuses from model-facing update input. */
|
|
323
|
+
export function normalizeStatus(
|
|
324
|
+
value: string | undefined,
|
|
325
|
+
): ScheduledTaskStatus | undefined {
|
|
326
|
+
if (value === "active" || value === "paused" || value === "blocked") {
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeFrequency(
|
|
333
|
+
value: unknown,
|
|
334
|
+
): ScheduledCalendarFrequency | undefined {
|
|
335
|
+
if (
|
|
336
|
+
value === "daily" ||
|
|
337
|
+
value === "weekly" ||
|
|
338
|
+
value === "monthly" ||
|
|
339
|
+
value === "yearly"
|
|
340
|
+
) {
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Rebuild recurrence only from validated daily-or-slower schedule requests. */
|
|
347
|
+
export function buildRecurrence(args: {
|
|
348
|
+
existing?: ScheduledTaskRecurrence;
|
|
349
|
+
input: {
|
|
350
|
+
recurrence?: unknown;
|
|
351
|
+
};
|
|
352
|
+
nextRunAtMs: number | undefined;
|
|
353
|
+
timezone: string;
|
|
354
|
+
}): ScheduledTaskRecurrence | undefined {
|
|
355
|
+
if (args.input.recurrence === null) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const frequency =
|
|
360
|
+
normalizeFrequency(args.input.recurrence) ?? args.existing?.frequency;
|
|
361
|
+
if (!frequency) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
if (!args.nextRunAtMs) {
|
|
365
|
+
throwToolInputError("Recurring scheduled tasks require next_run_at.");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
return buildCalendarRecurrence({
|
|
370
|
+
frequency,
|
|
371
|
+
interval: args.existing?.interval,
|
|
372
|
+
nextRunAtMs: args.nextRunAtMs,
|
|
373
|
+
timezone: args.timezone,
|
|
374
|
+
weekdays: frequency === "weekly" ? args.existing?.weekdays : undefined,
|
|
375
|
+
});
|
|
376
|
+
} catch (error) {
|
|
377
|
+
throwToolInputError(
|
|
378
|
+
error instanceof RangeError
|
|
379
|
+
? "timezone must be a valid IANA time zone."
|
|
380
|
+
: error instanceof Error
|
|
381
|
+
? error.message
|
|
382
|
+
: String(error),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Reject recurrence values that would exceed the scheduler cadence policy. */
|
|
388
|
+
export function validateRecurringFrequencyLimit(input: {
|
|
389
|
+
recurrence?: unknown;
|
|
390
|
+
}) {
|
|
391
|
+
if (
|
|
392
|
+
input.recurrence !== undefined &&
|
|
393
|
+
input.recurrence !== null &&
|
|
394
|
+
!normalizeFrequency(input.recurrence)
|
|
395
|
+
) {
|
|
396
|
+
throwToolInputError(
|
|
397
|
+
"Recurring scheduled tasks can run at most once per day.",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Force create-tool callers to explicitly choose one-off versus recurring semantics. */
|
|
403
|
+
export function validateCreateScheduleKind(input: {
|
|
404
|
+
recurrence?: unknown;
|
|
405
|
+
schedule_kind?: unknown;
|
|
406
|
+
}) {
|
|
407
|
+
if (input.schedule_kind === undefined) {
|
|
408
|
+
throwToolInputError("Provide schedule_kind as one_off or recurring.");
|
|
409
|
+
}
|
|
410
|
+
if (
|
|
411
|
+
input.schedule_kind !== "one_off" &&
|
|
412
|
+
input.schedule_kind !== "recurring"
|
|
413
|
+
) {
|
|
414
|
+
throwToolInputError("schedule_kind must be one_off or recurring.");
|
|
415
|
+
}
|
|
416
|
+
if (
|
|
417
|
+
input.schedule_kind === "one_off" &&
|
|
418
|
+
input.recurrence !== undefined &&
|
|
419
|
+
input.recurrence !== null
|
|
420
|
+
) {
|
|
421
|
+
throwToolInputError("Omit recurrence when schedule_kind is one_off.");
|
|
422
|
+
}
|
|
423
|
+
if (
|
|
424
|
+
input.schedule_kind === "recurring" &&
|
|
425
|
+
(input.recurrence === undefined || input.recurrence === null)
|
|
426
|
+
) {
|
|
427
|
+
throwToolInputError("Provide recurrence when schedule_kind is recurring.");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Detect update inputs that affect calendar recurrence materialization. */
|
|
432
|
+
export function shouldRebuildRecurrence(input: {
|
|
433
|
+
next_run_at?: string;
|
|
434
|
+
recurrence?: unknown;
|
|
435
|
+
timezone?: string;
|
|
436
|
+
}): boolean {
|
|
437
|
+
return (
|
|
438
|
+
input.next_run_at !== undefined ||
|
|
439
|
+
input.recurrence !== undefined ||
|
|
440
|
+
input.timezone !== undefined
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Centralize scheduler timezone defaulting for all concrete tool entry points. */
|
|
445
|
+
export function getDefaultScheduleTimezone(): string {
|
|
446
|
+
return process.env.JUNIOR_TIMEZONE?.trim() || DEFAULT_SCHEDULE_TIMEZONE;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Validate IANA timezone names before persisting scheduler cadence state. */
|
|
450
|
+
export function isValidTimeZone(timezone: string): boolean {
|
|
451
|
+
try {
|
|
452
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
|
|
453
|
+
return true;
|
|
454
|
+
} catch {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Parse model-supplied timestamps without leaking date parser details to tools. */
|
|
460
|
+
export function parseNextRunAtMs(
|
|
461
|
+
nextRunAtIso: string | undefined,
|
|
462
|
+
): number | undefined {
|
|
463
|
+
try {
|
|
464
|
+
if (nextRunAtIso) {
|
|
465
|
+
return parseScheduleTimestamp(nextRunAtIso);
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { definePluginTool } from "@sentry/junior-plugin-api";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { SCHEDULED_TASK_SYSTEM_ACTOR } from "../types";
|
|
4
|
+
import type { ScheduledTask } from "../types";
|
|
5
|
+
import {
|
|
6
|
+
buildRecurrence,
|
|
7
|
+
buildTaskId,
|
|
8
|
+
compactTask,
|
|
9
|
+
getConversationAccess,
|
|
10
|
+
getCredentialSubject,
|
|
11
|
+
getDefaultScheduleTimezone,
|
|
12
|
+
isValidTimeZone,
|
|
13
|
+
parseNextRunAtMs,
|
|
14
|
+
requireActiveConversation,
|
|
15
|
+
requireRequester,
|
|
16
|
+
scheduleTaskToolResult,
|
|
17
|
+
scheduleTaskToolResultSchema,
|
|
18
|
+
schedulerStore,
|
|
19
|
+
throwToolInputError,
|
|
20
|
+
validateCreateScheduleKind,
|
|
21
|
+
validateRecurringFrequencyLimit,
|
|
22
|
+
type SchedulerToolContext,
|
|
23
|
+
} from "../tool-support";
|
|
24
|
+
|
|
25
|
+
/** Create a tool that stores a scheduled task for the active Slack context. */
|
|
26
|
+
export function createSlackScheduleCreateTaskTool(
|
|
27
|
+
context: SchedulerToolContext,
|
|
28
|
+
) {
|
|
29
|
+
return definePluginTool({
|
|
30
|
+
description:
|
|
31
|
+
"Create a one-time or recurring Junior task in the active Slack conversation. For one-time reminders or one-time scheduled work, omit recurrence entirely; never choose a default recurrence. Use only when the user explicitly asks Junior to do work later or on a recurring cadence. Only manage tasks for the active Slack DM or channel; never target threads, other channels, or another user's DM. When the task, schedule, and destination are clear, create it without asking for confirmation; ask only when one of those is ambiguous.",
|
|
32
|
+
executionMode: "sequential",
|
|
33
|
+
inputSchema: z.object({
|
|
34
|
+
task: z.string().min(1).max(4000),
|
|
35
|
+
schedule: z.string().min(1).max(300),
|
|
36
|
+
schedule_kind: z
|
|
37
|
+
.enum(["one_off", "recurring"])
|
|
38
|
+
.describe(
|
|
39
|
+
"Required schedule classification. Use one_off for one-time reminders or one-time scheduled work. Use recurring only when the user explicitly asks for a repeating schedule.",
|
|
40
|
+
),
|
|
41
|
+
timezone: z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.max(80)
|
|
45
|
+
.describe(
|
|
46
|
+
"IANA timezone, e.g. 'America/Los_Angeles'. Defaults to the channel's configured timezone.",
|
|
47
|
+
)
|
|
48
|
+
.optional(),
|
|
49
|
+
next_run_at: z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1)
|
|
52
|
+
.describe(
|
|
53
|
+
"Exact next run time as an ISO timestamp, computed from the user's requested schedule.",
|
|
54
|
+
)
|
|
55
|
+
.optional(),
|
|
56
|
+
recurrence: z
|
|
57
|
+
.enum(["daily", "weekly", "monthly", "yearly"])
|
|
58
|
+
.nullable()
|
|
59
|
+
.describe(
|
|
60
|
+
"Required when schedule_kind is recurring. Omit when schedule_kind is one_off. Recurring tasks run at most once per day: use daily, weekly, monthly, or yearly only.",
|
|
61
|
+
)
|
|
62
|
+
.optional(),
|
|
63
|
+
}),
|
|
64
|
+
outputSchema: scheduleTaskToolResultSchema,
|
|
65
|
+
execute: async (input) => {
|
|
66
|
+
const destination = requireActiveConversation(context);
|
|
67
|
+
const requester = requireRequester(context);
|
|
68
|
+
|
|
69
|
+
const nowMs = Date.now();
|
|
70
|
+
const timezone = input.timezone ?? getDefaultScheduleTimezone();
|
|
71
|
+
validateCreateScheduleKind(input);
|
|
72
|
+
validateRecurringFrequencyLimit(input);
|
|
73
|
+
if (!isValidTimeZone(timezone)) {
|
|
74
|
+
throwToolInputError("timezone must be a valid IANA time zone.");
|
|
75
|
+
}
|
|
76
|
+
const nextRunAtMs = parseNextRunAtMs(input.next_run_at);
|
|
77
|
+
if (!nextRunAtMs) {
|
|
78
|
+
throwToolInputError("Provide next_run_at as a valid ISO timestamp.");
|
|
79
|
+
}
|
|
80
|
+
const recurrence = buildRecurrence({
|
|
81
|
+
input,
|
|
82
|
+
nextRunAtMs,
|
|
83
|
+
timezone,
|
|
84
|
+
});
|
|
85
|
+
const conversationAccess = getConversationAccess(destination);
|
|
86
|
+
const credentialSubject = getCredentialSubject({
|
|
87
|
+
access: conversationAccess,
|
|
88
|
+
subject: context.credentialSubject,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const task: ScheduledTask = {
|
|
92
|
+
id: buildTaskId(),
|
|
93
|
+
createdAtMs: nowMs,
|
|
94
|
+
updatedAtMs: nowMs,
|
|
95
|
+
createdBy: requester,
|
|
96
|
+
conversationAccess,
|
|
97
|
+
...(credentialSubject ? { credentialSubject } : {}),
|
|
98
|
+
destination,
|
|
99
|
+
executionActor: SCHEDULED_TASK_SYSTEM_ACTOR,
|
|
100
|
+
nextRunAtMs,
|
|
101
|
+
originalRequest: context.userText,
|
|
102
|
+
schedule: {
|
|
103
|
+
description: input.schedule,
|
|
104
|
+
timezone,
|
|
105
|
+
kind: recurrence ? "recurring" : "one_off",
|
|
106
|
+
recurrence,
|
|
107
|
+
},
|
|
108
|
+
status: "active",
|
|
109
|
+
task: {
|
|
110
|
+
text: input.task,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
await schedulerStore(context).saveTask(task);
|
|
115
|
+
return scheduleTaskToolResult(
|
|
116
|
+
"slackScheduleCreateTask",
|
|
117
|
+
compactTask(task),
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { definePluginTool } from "@sentry/junior-plugin-api";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { ScheduledTask } from "../types";
|
|
4
|
+
import {
|
|
5
|
+
compactTask,
|
|
6
|
+
getWritableTask,
|
|
7
|
+
scheduleTaskToolResult,
|
|
8
|
+
scheduleTaskToolResultSchema,
|
|
9
|
+
schedulerStore,
|
|
10
|
+
type SchedulerToolContext,
|
|
11
|
+
} from "../tool-support";
|
|
12
|
+
|
|
13
|
+
/** Create a tool that removes a scheduled task from the active Slack conversation. */
|
|
14
|
+
export function createSlackScheduleDeleteTaskTool(
|
|
15
|
+
context: SchedulerToolContext,
|
|
16
|
+
) {
|
|
17
|
+
return definePluginTool({
|
|
18
|
+
description:
|
|
19
|
+
"Delete one scheduled Junior task from the active Slack conversation. Use only task IDs returned for this conversation. Do not delete schedules from threads, other channels, or another user's DM.",
|
|
20
|
+
executionMode: "sequential",
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
task_id: z
|
|
23
|
+
.string()
|
|
24
|
+
.min(1)
|
|
25
|
+
.describe(
|
|
26
|
+
"ID of the task to delete. Must be from this active Slack conversation.",
|
|
27
|
+
),
|
|
28
|
+
}),
|
|
29
|
+
outputSchema: scheduleTaskToolResultSchema,
|
|
30
|
+
execute: async ({ task_id }) => {
|
|
31
|
+
const lookup = await getWritableTask({ context, taskId: task_id });
|
|
32
|
+
|
|
33
|
+
const next: ScheduledTask = {
|
|
34
|
+
...lookup,
|
|
35
|
+
updatedAtMs: Date.now(),
|
|
36
|
+
status: "deleted",
|
|
37
|
+
nextRunAtMs: undefined,
|
|
38
|
+
runNowAtMs: undefined,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
await schedulerStore(context).saveTask(next);
|
|
42
|
+
return scheduleTaskToolResult(
|
|
43
|
+
"slackScheduleDeleteTask",
|
|
44
|
+
compactTask(next),
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { definePluginTool } from "@sentry/junior-plugin-api";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
compactTask,
|
|
5
|
+
MAX_LISTED_TASKS,
|
|
6
|
+
requireActiveConversation,
|
|
7
|
+
sameDestination,
|
|
8
|
+
scheduleListToolResult,
|
|
9
|
+
scheduleListToolResultSchema,
|
|
10
|
+
schedulerStore,
|
|
11
|
+
type SchedulerToolContext,
|
|
12
|
+
} from "../tool-support";
|
|
13
|
+
|
|
14
|
+
/** Create a tool that lists scheduled tasks for the active Slack conversation. */
|
|
15
|
+
export function createSlackScheduleListTasksTool(
|
|
16
|
+
context: SchedulerToolContext,
|
|
17
|
+
) {
|
|
18
|
+
return definePluginTool({
|
|
19
|
+
description:
|
|
20
|
+
"List scheduled Junior tasks for the active Slack conversation. Use when the user asks what is scheduled here, or when task IDs are needed before editing, deleting, or running schedules. Only manages tasks for the active Slack DM or channel.",
|
|
21
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
22
|
+
inputSchema: z.object({}),
|
|
23
|
+
outputSchema: scheduleListToolResultSchema,
|
|
24
|
+
execute: async () => {
|
|
25
|
+
const destination = requireActiveConversation(context);
|
|
26
|
+
|
|
27
|
+
const tasks = await schedulerStore(context).listTasksForTeam(
|
|
28
|
+
destination.teamId,
|
|
29
|
+
);
|
|
30
|
+
const matching = tasks.filter((task) =>
|
|
31
|
+
sameDestination(task, destination),
|
|
32
|
+
);
|
|
33
|
+
const visible = matching.slice(0, MAX_LISTED_TASKS).map(compactTask);
|
|
34
|
+
|
|
35
|
+
return scheduleListToolResult({
|
|
36
|
+
target: "slackScheduleListTasks",
|
|
37
|
+
tasks: visible,
|
|
38
|
+
truncated: matching.length > visible.length,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|