@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.
@@ -0,0 +1,640 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Type } from "@sinclair/typebox";
3
+ import {
4
+ AgentPluginToolInputError,
5
+ type AgentPluginRequester,
6
+ type AgentPluginState,
7
+ type AgentPluginToolDefinition,
8
+ } from "@sentry/junior-plugin-api";
9
+ import { buildCalendarRecurrence, parseScheduleTimestamp } from "./cadence";
10
+ import { createSchedulerStore } from "./store";
11
+ import { SCHEDULED_TASK_SYSTEM_ACTOR } from "./types";
12
+ import type {
13
+ ScheduledCalendarFrequency,
14
+ ScheduledTask,
15
+ ScheduledTaskConversationAccess,
16
+ ScheduledTaskCredentialSubject,
17
+ ScheduledTaskDestination,
18
+ ScheduledTaskPrincipal,
19
+ ScheduledTaskRecurrence,
20
+ ScheduledTaskStatus,
21
+ } from "./types";
22
+
23
+ export interface SchedulerToolContext {
24
+ channelCapabilities: {
25
+ canAddReactions: boolean;
26
+ canCreateCanvas: boolean;
27
+ canPostToChannel: boolean;
28
+ };
29
+ channelId?: string;
30
+ messageTs?: string;
31
+ requester?: AgentPluginRequester;
32
+ state: AgentPluginState;
33
+ teamId?: string;
34
+ threadTs?: string;
35
+ userText?: string;
36
+ }
37
+
38
+ const TASK_ID_PREFIX = "sched";
39
+ const MAX_LISTED_TASKS = 50;
40
+ const DEFAULT_SCHEDULE_TIMEZONE = "America/Los_Angeles";
41
+ const ACTIVE_DESTINATION_GUIDELINE =
42
+ "Only manage tasks for the active Slack DM or channel; never target an existing thread, another channel, or another user's DM.";
43
+ const ACTIVE_TASK_ID_GUIDELINE =
44
+ "Use only task IDs returned from this active destination.";
45
+ const RECURRING_GUIDELINE =
46
+ "Omit recurrence for one-time requests like 'in 1 minute', 'tomorrow', or a specific date; provide recurrence only for requests that explicitly repeat.";
47
+
48
+ const recurrenceInputSchema = Type.Union([
49
+ Type.Literal("daily"),
50
+ Type.Literal("weekly"),
51
+ Type.Literal("monthly"),
52
+ Type.Literal("yearly"),
53
+ ]);
54
+
55
+ function throwToolInputError(error: string): never {
56
+ throw new AgentPluginToolInputError(error);
57
+ }
58
+
59
+ function requireActiveDestination(
60
+ context: SchedulerToolContext,
61
+ ): ScheduledTaskDestination {
62
+ const channelId = normalizeSlackConversationId(context.channelId);
63
+ if (!channelId) {
64
+ throwToolInputError("No active Slack channel context is available.");
65
+ }
66
+ if (!context.teamId) {
67
+ throwToolInputError("No active Slack workspace context is available.");
68
+ }
69
+ if (!isSlackTeamId(context.teamId)) {
70
+ throwToolInputError("Active Slack workspace context is invalid.");
71
+ }
72
+
73
+ return {
74
+ platform: "slack",
75
+ teamId: context.teamId,
76
+ channelId,
77
+ };
78
+ }
79
+
80
+ function requireRequester(
81
+ context: SchedulerToolContext,
82
+ ): ScheduledTaskPrincipal {
83
+ const userId = context.requester?.userId;
84
+ if (!userId) {
85
+ throwToolInputError("No active Slack requester context is available.");
86
+ }
87
+
88
+ return {
89
+ slackUserId: userId,
90
+ ...(context.requester?.userName
91
+ ? { userName: context.requester.userName }
92
+ : {}),
93
+ ...(context.requester?.fullName
94
+ ? { fullName: context.requester.fullName }
95
+ : {}),
96
+ };
97
+ }
98
+
99
+ function tool<TInput = any>(
100
+ definition: AgentPluginToolDefinition<TInput>,
101
+ ): AgentPluginToolDefinition<TInput> {
102
+ return definition;
103
+ }
104
+
105
+ function normalizeSlackConversationId(
106
+ value: string | undefined,
107
+ ): string | undefined {
108
+ if (!value) return undefined;
109
+ const trimmed = value.trim();
110
+ if (!trimmed) return undefined;
111
+ if (!trimmed.startsWith("slack:")) return trimmed;
112
+
113
+ const parts = trimmed.split(":");
114
+ return parts[1]?.trim() || undefined;
115
+ }
116
+
117
+ function isDmChannel(channelId: string): boolean {
118
+ return normalizeSlackConversationId(channelId)?.startsWith("D") ?? false;
119
+ }
120
+
121
+ function isSlackTeamId(value: string): boolean {
122
+ return /^T[A-Z0-9]+$/.test(value);
123
+ }
124
+
125
+ function getConversationAccess(
126
+ destination: ScheduledTaskDestination,
127
+ ): ScheduledTaskConversationAccess {
128
+ if (isDmChannel(destination.channelId)) {
129
+ return { audience: "direct", visibility: "private" };
130
+ }
131
+ if (destination.channelId.startsWith("G")) {
132
+ return { audience: "group", visibility: "private" };
133
+ }
134
+ if (destination.channelId.startsWith("C")) {
135
+ return { audience: "channel", visibility: "unknown" };
136
+ }
137
+ return { audience: "channel", visibility: "unknown" };
138
+ }
139
+
140
+ function getCredentialSubject(args: {
141
+ access: ScheduledTaskConversationAccess;
142
+ requester: ScheduledTaskPrincipal;
143
+ }): ScheduledTaskCredentialSubject | undefined {
144
+ if (
145
+ args.access.audience !== "direct" ||
146
+ args.access.visibility !== "private"
147
+ ) {
148
+ return undefined;
149
+ }
150
+ return {
151
+ type: "user",
152
+ userId: args.requester.slackUserId,
153
+ allowedWhen: "private-direct-conversation",
154
+ };
155
+ }
156
+
157
+ function sameDestination(
158
+ task: ScheduledTask,
159
+ destination: ScheduledTaskDestination,
160
+ ): boolean {
161
+ return (
162
+ task.destination.platform === destination.platform &&
163
+ task.destination.teamId === destination.teamId &&
164
+ task.destination.channelId === destination.channelId
165
+ );
166
+ }
167
+
168
+ async function getWritableTask(args: {
169
+ context: SchedulerToolContext;
170
+ taskId: string;
171
+ }): Promise<ScheduledTask> {
172
+ const destination = requireActiveDestination(args.context);
173
+
174
+ const task = await createSchedulerStore(args.context.state).getTask(
175
+ args.taskId,
176
+ );
177
+ if (!task || task.status === "deleted") {
178
+ throwToolInputError(
179
+ "Scheduled task was not found in the active destination.",
180
+ );
181
+ }
182
+
183
+ if (!sameDestination(task, destination)) {
184
+ throwToolInputError(
185
+ "Scheduled task can only be managed from the Slack destination where it was created.",
186
+ );
187
+ }
188
+ return task;
189
+ }
190
+
191
+ function compactTask(task: ScheduledTask): Record<string, unknown> {
192
+ return {
193
+ id: task.id,
194
+ status: task.status,
195
+ task: task.task.text,
196
+ schedule: task.schedule.description,
197
+ timezone: task.schedule.timezone,
198
+ recurrence: task.schedule.recurrence
199
+ ? {
200
+ frequency: task.schedule.recurrence.frequency,
201
+ interval: task.schedule.recurrence.interval,
202
+ start_date: task.schedule.recurrence.startDate,
203
+ time: task.schedule.recurrence.time,
204
+ weekdays: task.schedule.recurrence.weekdays,
205
+ month: task.schedule.recurrence.month,
206
+ day_of_month: task.schedule.recurrence.dayOfMonth,
207
+ }
208
+ : null,
209
+ next_run_at: task.nextRunAtMs
210
+ ? new Date(task.nextRunAtMs).toISOString()
211
+ : null,
212
+ conversation_access: task.conversationAccess ?? null,
213
+ credential_subject: task.credentialSubject
214
+ ? {
215
+ type: task.credentialSubject.type,
216
+ allowed_when: task.credentialSubject.allowedWhen,
217
+ }
218
+ : null,
219
+ last_run_at: task.lastRunAtMs
220
+ ? new Date(task.lastRunAtMs).toISOString()
221
+ : null,
222
+ run_now_at: task.runNowAtMs
223
+ ? new Date(task.runNowAtMs).toISOString()
224
+ : null,
225
+ version: task.version,
226
+ };
227
+ }
228
+
229
+ function buildTaskId(): string {
230
+ return `${TASK_ID_PREFIX}_${randomUUID()}`;
231
+ }
232
+
233
+ function normalizeStatus(
234
+ value: string | undefined,
235
+ ): ScheduledTaskStatus | undefined {
236
+ if (value === "active" || value === "paused" || value === "blocked") {
237
+ return value;
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ function normalizeFrequency(
243
+ value: unknown,
244
+ ): ScheduledCalendarFrequency | undefined {
245
+ if (
246
+ value === "daily" ||
247
+ value === "weekly" ||
248
+ value === "monthly" ||
249
+ value === "yearly"
250
+ ) {
251
+ return value;
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ function buildRecurrence(args: {
257
+ existing?: ScheduledTaskRecurrence;
258
+ input: {
259
+ recurrence?: unknown;
260
+ };
261
+ nextRunAtMs: number | undefined;
262
+ timezone: string;
263
+ }): ScheduledTaskRecurrence | undefined {
264
+ if (args.input.recurrence === null) {
265
+ return undefined;
266
+ }
267
+
268
+ const frequency =
269
+ normalizeFrequency(args.input.recurrence) ?? args.existing?.frequency;
270
+ if (!frequency) {
271
+ return undefined;
272
+ }
273
+ if (!args.nextRunAtMs) {
274
+ throwToolInputError("Recurring scheduled tasks require next_run_at.");
275
+ }
276
+
277
+ try {
278
+ return buildCalendarRecurrence({
279
+ frequency,
280
+ interval: args.existing?.interval,
281
+ nextRunAtMs: args.nextRunAtMs,
282
+ timezone: args.timezone,
283
+ weekdays: frequency === "weekly" ? args.existing?.weekdays : undefined,
284
+ });
285
+ } catch (error) {
286
+ throwToolInputError(
287
+ error instanceof RangeError
288
+ ? "timezone must be a valid IANA time zone."
289
+ : error instanceof Error
290
+ ? error.message
291
+ : String(error),
292
+ );
293
+ }
294
+ }
295
+
296
+ function validateRecurringFrequencyLimit(input: { recurrence?: unknown }) {
297
+ if (
298
+ input.recurrence !== undefined &&
299
+ input.recurrence !== null &&
300
+ !normalizeFrequency(input.recurrence)
301
+ ) {
302
+ throwToolInputError(
303
+ "Recurring scheduled tasks can run at most once per day.",
304
+ );
305
+ }
306
+ }
307
+
308
+ function shouldRebuildRecurrence(input: {
309
+ next_run_at?: string;
310
+ recurrence?: unknown;
311
+ timezone?: string;
312
+ }): boolean {
313
+ return (
314
+ input.next_run_at !== undefined ||
315
+ input.recurrence !== undefined ||
316
+ input.timezone !== undefined
317
+ );
318
+ }
319
+
320
+ function getDefaultScheduleTimezone(): string {
321
+ return process.env.JUNIOR_TIMEZONE?.trim() || DEFAULT_SCHEDULE_TIMEZONE;
322
+ }
323
+
324
+ function isValidTimeZone(timezone: string): boolean {
325
+ try {
326
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
327
+ return true;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ function parseNextRunAtMs(
334
+ nextRunAtIso: string | undefined,
335
+ ): number | undefined {
336
+ try {
337
+ if (nextRunAtIso) {
338
+ return parseScheduleTimestamp(nextRunAtIso);
339
+ }
340
+ } catch {
341
+ return undefined;
342
+ }
343
+ return undefined;
344
+ }
345
+
346
+ /** Create a tool that stores a scheduled task for the active Slack context. */
347
+ export function createSlackScheduleCreateTaskTool(
348
+ context: SchedulerToolContext,
349
+ ) {
350
+ return tool({
351
+ description:
352
+ "Create a scheduled Junior task in the active Slack conversation.",
353
+ promptSnippet: "create future or recurring Junior work here",
354
+ promptGuidelines: [
355
+ "Use only when the user explicitly asks Junior to do work later or on a recurring cadence.",
356
+ ACTIVE_DESTINATION_GUIDELINE,
357
+ RECURRING_GUIDELINE,
358
+ "When the user's scheduling intent is clear, create the task immediately without asking for confirmation.",
359
+ "Ask for confirmation only when the task contract, schedule, or active destination is ambiguous.",
360
+ "Recurring tasks can run at most once per day; use only daily, weekly, monthly, or yearly recurrence frequencies.",
361
+ "Provide next_run_at as an exact ISO timestamp computed from the user's requested schedule.",
362
+ "Provide recurrence only for repeating schedules.",
363
+ ],
364
+ inputSchema: Type.Object({
365
+ task: Type.String({ minLength: 1, maxLength: 4000 }),
366
+ schedule: Type.String({ minLength: 1, maxLength: 300 }),
367
+ timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
368
+ next_run_at: Type.Optional(
369
+ Type.String({
370
+ minLength: 1,
371
+ description:
372
+ "Exact next run time as an ISO timestamp, computed from the user's requested schedule.",
373
+ }),
374
+ ),
375
+ recurrence: Type.Optional(recurrenceInputSchema),
376
+ }),
377
+ execute: async (input) => {
378
+ const destination = requireActiveDestination(context);
379
+ const requester = requireRequester(context);
380
+
381
+ const nowMs = Date.now();
382
+ const timezone = input.timezone ?? getDefaultScheduleTimezone();
383
+ validateRecurringFrequencyLimit(input);
384
+ if (!isValidTimeZone(timezone)) {
385
+ throwToolInputError("timezone must be a valid IANA time zone.");
386
+ }
387
+ const nextRunAtMs = parseNextRunAtMs(input.next_run_at);
388
+ if (!nextRunAtMs) {
389
+ throwToolInputError("Provide next_run_at as a valid ISO timestamp.");
390
+ }
391
+ const recurrence = buildRecurrence({
392
+ input,
393
+ nextRunAtMs,
394
+ timezone,
395
+ });
396
+ const conversationAccess = getConversationAccess(destination);
397
+ const credentialSubject = getCredentialSubject({
398
+ access: conversationAccess,
399
+ requester,
400
+ });
401
+
402
+ const task: ScheduledTask = {
403
+ id: buildTaskId(),
404
+ createdAtMs: nowMs,
405
+ updatedAtMs: nowMs,
406
+ createdBy: requester,
407
+ conversationAccess,
408
+ ...(credentialSubject ? { credentialSubject } : {}),
409
+ destination,
410
+ executionActor: SCHEDULED_TASK_SYSTEM_ACTOR,
411
+ nextRunAtMs,
412
+ originalRequest: context.userText,
413
+ schedule: {
414
+ description: input.schedule,
415
+ timezone,
416
+ kind: recurrence ? "recurring" : "one_off",
417
+ recurrence,
418
+ },
419
+ status: "active",
420
+ task: {
421
+ text: input.task,
422
+ },
423
+ version: 1,
424
+ };
425
+
426
+ await createSchedulerStore(context.state).saveTask(task);
427
+ return {
428
+ ok: true,
429
+ task: compactTask(task),
430
+ };
431
+ },
432
+ });
433
+ }
434
+
435
+ /** Create a tool that lists scheduled tasks for the active Slack destination. */
436
+ export function createSlackScheduleListTasksTool(
437
+ context: SchedulerToolContext,
438
+ ) {
439
+ return tool({
440
+ description:
441
+ "List scheduled Junior tasks for the active Slack conversation.",
442
+ promptSnippet: "list schedules for this Slack destination",
443
+ promptGuidelines: [
444
+ "Use when the user asks what is scheduled here or needs task IDs before editing, deleting, or running schedules.",
445
+ ACTIVE_DESTINATION_GUIDELINE,
446
+ ],
447
+ annotations: { readOnlyHint: true, destructiveHint: false },
448
+ inputSchema: Type.Object({}),
449
+ execute: async () => {
450
+ const destination = requireActiveDestination(context);
451
+
452
+ const tasks = await createSchedulerStore(context.state).listTasksForTeam(
453
+ destination.teamId,
454
+ );
455
+ const matching = tasks.filter((task) =>
456
+ sameDestination(task, destination),
457
+ );
458
+ const visible = matching.slice(0, MAX_LISTED_TASKS).map(compactTask);
459
+
460
+ return {
461
+ ok: true,
462
+ tasks: visible,
463
+ truncated: matching.length > visible.length,
464
+ };
465
+ },
466
+ });
467
+ }
468
+
469
+ /** Create a tool that edits a scheduled task in the active Slack destination. */
470
+ export function createSlackScheduleUpdateTaskTool(
471
+ context: SchedulerToolContext,
472
+ ) {
473
+ return tool({
474
+ description: "Edit, pause, resume, or reschedule a Junior scheduled task.",
475
+ promptSnippet: "edit/pause/resume one schedule in this Slack destination",
476
+ promptGuidelines: [
477
+ ACTIVE_TASK_ID_GUIDELINE,
478
+ ACTIVE_DESTINATION_GUIDELINE,
479
+ RECURRING_GUIDELINE,
480
+ "Do not move scheduled tasks across conversations.",
481
+ "Provide next_run_at as an exact ISO timestamp when changing the next run.",
482
+ "Set recurrence to null when converting a recurring task to one-time.",
483
+ "Set status to active, paused, or blocked when the user asks to resume, pause, or block a task.",
484
+ ],
485
+ inputSchema: Type.Object({
486
+ task_id: Type.String({ minLength: 1 }),
487
+ task: Type.Optional(Type.String({ minLength: 1, maxLength: 4000 })),
488
+ schedule: Type.Optional(Type.String({ minLength: 1, maxLength: 300 })),
489
+ timezone: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
490
+ next_run_at: Type.Optional(Type.String({ minLength: 1 })),
491
+ recurrence: Type.Optional(
492
+ Type.Union([recurrenceInputSchema, Type.Null()]),
493
+ ),
494
+ status: Type.Optional(
495
+ Type.Union([
496
+ Type.Literal("active"),
497
+ Type.Literal("paused"),
498
+ Type.Literal("blocked"),
499
+ ]),
500
+ ),
501
+ }),
502
+ execute: async (input) => {
503
+ const lookup = await getWritableTask({
504
+ context,
505
+ taskId: input.task_id,
506
+ });
507
+
508
+ const timezone = input.timezone ?? lookup.schedule.timezone;
509
+ validateRecurringFrequencyLimit(input);
510
+ if (!isValidTimeZone(timezone)) {
511
+ throwToolInputError("timezone must be a valid IANA time zone.");
512
+ }
513
+ const parsedNextRunAtMs = parseNextRunAtMs(input.next_run_at);
514
+ const nextRunAtMs = input.next_run_at
515
+ ? parsedNextRunAtMs
516
+ : lookup.nextRunAtMs;
517
+ if (input.next_run_at && !nextRunAtMs) {
518
+ throwToolInputError("Provide next_run_at as a valid ISO timestamp.");
519
+ }
520
+
521
+ const status = normalizeStatus(input.status);
522
+ if (input.status && !status) {
523
+ throwToolInputError("status must be active, paused, or blocked.");
524
+ }
525
+ if (status === "active" && !nextRunAtMs) {
526
+ throwToolInputError(
527
+ "Active scheduled tasks require next_run_at when no next run is stored.",
528
+ );
529
+ }
530
+ const recurrence = shouldRebuildRecurrence(input)
531
+ ? buildRecurrence({
532
+ existing: lookup.schedule.recurrence,
533
+ input,
534
+ nextRunAtMs,
535
+ timezone,
536
+ })
537
+ : lookup.schedule.recurrence;
538
+ const nextStatus = status ?? lookup.status;
539
+
540
+ const next: ScheduledTask = {
541
+ ...lookup,
542
+ updatedAtMs: Date.now(),
543
+ nextRunAtMs,
544
+ runNowAtMs: nextStatus === "active" ? lookup.runNowAtMs : undefined,
545
+ status: nextStatus,
546
+ statusReason:
547
+ nextStatus === "blocked" ? lookup.statusReason : undefined,
548
+ schedule: {
549
+ ...lookup.schedule,
550
+ description: input.schedule ?? lookup.schedule.description,
551
+ timezone,
552
+ kind: recurrence ? "recurring" : "one_off",
553
+ recurrence,
554
+ },
555
+ task: input.task ? { text: input.task } : lookup.task,
556
+ version: lookup.version + 1,
557
+ };
558
+
559
+ await createSchedulerStore(context.state).saveTask(next);
560
+ return {
561
+ ok: true,
562
+ task: compactTask(next),
563
+ };
564
+ },
565
+ });
566
+ }
567
+
568
+ /** Create a tool that removes a scheduled task from the active Slack destination. */
569
+ export function createSlackScheduleDeleteTaskTool(
570
+ context: SchedulerToolContext,
571
+ ) {
572
+ return tool({
573
+ description:
574
+ "Delete a Junior scheduled task from the active Slack conversation.",
575
+ promptSnippet: "delete one schedule from this Slack destination",
576
+ promptGuidelines: [ACTIVE_TASK_ID_GUIDELINE, ACTIVE_DESTINATION_GUIDELINE],
577
+ inputSchema: Type.Object({
578
+ task_id: Type.String({ minLength: 1 }),
579
+ }),
580
+ execute: async ({ task_id }) => {
581
+ const lookup = await getWritableTask({ context, taskId: task_id });
582
+
583
+ const next: ScheduledTask = {
584
+ ...lookup,
585
+ updatedAtMs: Date.now(),
586
+ status: "deleted",
587
+ nextRunAtMs: undefined,
588
+ runNowAtMs: undefined,
589
+ version: lookup.version + 1,
590
+ };
591
+
592
+ await createSchedulerStore(context.state).saveTask(next);
593
+ return {
594
+ ok: true,
595
+ task: compactTask(next),
596
+ };
597
+ },
598
+ });
599
+ }
600
+
601
+ /** Create a tool that marks an existing scheduled task due immediately. */
602
+ export function createSlackScheduleRunTaskNowTool(
603
+ context: SchedulerToolContext,
604
+ ) {
605
+ return tool({
606
+ description:
607
+ "Queue an active Junior scheduled task to run as soon as possible.",
608
+ promptSnippet: "run one active schedule now without changing its cadence",
609
+ promptGuidelines: [
610
+ ACTIVE_TASK_ID_GUIDELINE,
611
+ ACTIVE_DESTINATION_GUIDELINE,
612
+ "Use when the user asks to run an existing scheduled task now; do not rewrite the stored calendar cadence.",
613
+ ],
614
+ inputSchema: Type.Object({
615
+ task_id: Type.String({ minLength: 1 }),
616
+ }),
617
+ execute: async ({ task_id }) => {
618
+ const lookup = await getWritableTask({ context, taskId: task_id });
619
+ if (lookup.status !== "active") {
620
+ throwToolInputError(
621
+ "Scheduled task must be active before it can be run now. Resume the task first if you want it to run.",
622
+ );
623
+ }
624
+
625
+ const nowMs = Date.now();
626
+ const next: ScheduledTask = {
627
+ ...lookup,
628
+ updatedAtMs: nowMs,
629
+ runNowAtMs: nowMs,
630
+ version: lookup.version + 1,
631
+ };
632
+
633
+ await createSchedulerStore(context.state).saveTask(next);
634
+ return {
635
+ ok: true,
636
+ task: compactTask(next),
637
+ };
638
+ },
639
+ });
640
+ }