@sentry/junior-scheduler 0.107.1 → 0.109.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.
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash } from "node:crypto";
2
2
  import {
3
3
  PluginToolInputError,
4
4
  pluginToolResultSchema,
@@ -8,20 +8,18 @@ import {
8
8
  type SlackSource,
9
9
  } from "@sentry/junior-plugin-api";
10
10
  import { z } from "zod";
11
- import { buildCalendarRecurrence, parseScheduleTimestamp } from "./cadence";
12
11
  import { sanitizeScheduledTaskPrincipal } from "./identity";
13
12
  import { type SchedulerStore } from "./store";
14
13
  import type {
15
- ScheduledCalendarFrequency,
16
14
  ScheduledTask,
17
15
  ScheduledTaskConversationAccess,
18
16
  ScheduledTaskPrincipal,
19
- ScheduledTaskRecurrence,
20
17
  ScheduledTaskStatus,
21
18
  } from "./types";
22
19
 
23
20
  export interface SchedulerToolContext {
24
21
  actor?: SlackActor;
22
+ now?: () => number;
25
23
  source?: SlackSource;
26
24
  store: SchedulerStore;
27
25
  userText?: string;
@@ -277,9 +275,29 @@ export function scheduleListToolResult(args: {
277
275
  } as const;
278
276
  }
279
277
 
280
- /** Prefix generated scheduler ids so tool results are distinguishable from provider ids. */
281
- export function buildTaskId(): string {
282
- return `${TASK_ID_PREFIX}_${randomUUID()}`;
278
+ /** Build a retry-stable scheduler id scoped to the creating actor and destination. */
279
+ export function buildTaskId(args: {
280
+ actor: ScheduledTaskPrincipal;
281
+ destination: SlackDestination;
282
+ toolCallId: string | undefined;
283
+ }): string {
284
+ const toolCallId = args.toolCallId?.trim();
285
+ if (!toolCallId) {
286
+ throw new Error("Scheduler task creation requires a tool-call identity.");
287
+ }
288
+ const digest = createHash("sha256")
289
+ .update(
290
+ JSON.stringify({
291
+ actor: args.actor.slackUserId,
292
+ channel: args.destination.channelId,
293
+ operation: toolCallId,
294
+ platform: args.destination.platform,
295
+ team: args.destination.teamId,
296
+ }),
297
+ )
298
+ .digest("hex")
299
+ .slice(0, 32);
300
+ return `${TASK_ID_PREFIX}_${digest}`;
283
301
  }
284
302
 
285
303
  /** Keep concrete scheduler tools coupled to the injected store, not global state. */
@@ -297,143 +315,7 @@ export function normalizeStatus(
297
315
  return undefined;
298
316
  }
299
317
 
300
- function normalizeFrequency(
301
- value: unknown,
302
- ): ScheduledCalendarFrequency | undefined {
303
- if (
304
- value === "daily" ||
305
- value === "weekly" ||
306
- value === "monthly" ||
307
- value === "yearly"
308
- ) {
309
- return value;
310
- }
311
- return undefined;
312
- }
313
-
314
- /** Rebuild recurrence only from validated daily-or-slower schedule requests. */
315
- export function buildRecurrence(args: {
316
- existing?: ScheduledTaskRecurrence;
317
- input: {
318
- recurrence?: unknown;
319
- };
320
- nextRunAtMs: number | undefined;
321
- timezone: string;
322
- }): ScheduledTaskRecurrence | undefined {
323
- if (args.input.recurrence === null) {
324
- return undefined;
325
- }
326
-
327
- const frequency =
328
- normalizeFrequency(args.input.recurrence) ?? args.existing?.frequency;
329
- if (!frequency) {
330
- return undefined;
331
- }
332
- if (!args.nextRunAtMs) {
333
- throwToolInputError("Recurring scheduled tasks require next_run_at.");
334
- }
335
-
336
- try {
337
- return buildCalendarRecurrence({
338
- frequency,
339
- interval: args.existing?.interval,
340
- nextRunAtMs: args.nextRunAtMs,
341
- timezone: args.timezone,
342
- weekdays: frequency === "weekly" ? args.existing?.weekdays : undefined,
343
- });
344
- } catch (error) {
345
- throwToolInputError(
346
- error instanceof RangeError
347
- ? "timezone must be a valid IANA time zone."
348
- : error instanceof Error
349
- ? error.message
350
- : String(error),
351
- );
352
- }
353
- }
354
-
355
- /** Reject recurrence values that would exceed the scheduler cadence policy. */
356
- export function validateRecurringFrequencyLimit(input: {
357
- recurrence?: unknown;
358
- }) {
359
- if (
360
- input.recurrence !== undefined &&
361
- input.recurrence !== null &&
362
- !normalizeFrequency(input.recurrence)
363
- ) {
364
- throwToolInputError(
365
- "Recurring scheduled tasks can run at most once per day.",
366
- );
367
- }
368
- }
369
-
370
- /** Force create-tool callers to explicitly choose one-off versus recurring semantics. */
371
- export function validateCreateScheduleKind(input: {
372
- recurrence?: unknown;
373
- schedule_kind?: unknown;
374
- }) {
375
- if (input.schedule_kind === undefined) {
376
- throwToolInputError("Provide schedule_kind as one_off or recurring.");
377
- }
378
- if (
379
- input.schedule_kind !== "one_off" &&
380
- input.schedule_kind !== "recurring"
381
- ) {
382
- throwToolInputError("schedule_kind must be one_off or recurring.");
383
- }
384
- if (
385
- input.schedule_kind === "one_off" &&
386
- input.recurrence !== undefined &&
387
- input.recurrence !== null
388
- ) {
389
- throwToolInputError("Omit recurrence when schedule_kind is one_off.");
390
- }
391
- if (
392
- input.schedule_kind === "recurring" &&
393
- (input.recurrence === undefined || input.recurrence === null)
394
- ) {
395
- throwToolInputError("Provide recurrence when schedule_kind is recurring.");
396
- }
397
- }
398
-
399
- /** Detect update inputs that affect calendar recurrence materialization. */
400
- export function shouldRebuildRecurrence(input: {
401
- next_run_at?: string;
402
- recurrence?: unknown;
403
- timezone?: string;
404
- }): boolean {
405
- return (
406
- input.next_run_at !== undefined ||
407
- input.recurrence !== undefined ||
408
- input.timezone !== undefined
409
- );
410
- }
411
-
412
318
  /** Centralize scheduler timezone defaulting for all concrete tool entry points. */
413
319
  export function getDefaultScheduleTimezone(): string {
414
320
  return process.env.JUNIOR_TIMEZONE?.trim() || DEFAULT_SCHEDULE_TIMEZONE;
415
321
  }
416
-
417
- /** Validate IANA timezone names before persisting scheduler cadence state. */
418
- export function isValidTimeZone(timezone: string): boolean {
419
- try {
420
- new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format();
421
- return true;
422
- } catch {
423
- return false;
424
- }
425
- }
426
-
427
- /** Parse model-supplied timestamps without leaking date parser details to tools. */
428
- export function parseNextRunAtMs(
429
- nextRunAtIso: string | undefined,
430
- ): number | undefined {
431
- try {
432
- if (nextRunAtIso) {
433
- return parseScheduleTimestamp(nextRunAtIso);
434
- }
435
- } catch {
436
- return undefined;
437
- }
438
- return undefined;
439
- }
@@ -1,23 +1,24 @@
1
1
  import { definePluginTool } from "@sentry/junior-plugin-api";
2
2
  import { z } from "zod";
3
+ import {
4
+ compileScheduleIntent,
5
+ ScheduleIntentError,
6
+ scheduleIntentSchema,
7
+ } from "../schedule-intent";
3
8
  import { SCHEDULED_TASK_SYSTEM_ACTOR } from "../types";
4
9
  import type { ScheduledTask } from "../types";
5
10
  import {
6
- buildRecurrence,
7
11
  buildTaskId,
8
12
  compactTask,
9
13
  getConversationAccess,
10
14
  getDefaultScheduleTimezone,
11
- isValidTimeZone,
12
- parseNextRunAtMs,
13
15
  requireActiveConversation,
14
16
  requireActor,
17
+ sameDestination,
15
18
  scheduleTaskToolResult,
16
19
  scheduleTaskToolResultSchema,
17
20
  schedulerStore,
18
21
  throwToolInputError,
19
- validateCreateScheduleKind,
20
- validateRecurringFrequencyLimit,
21
22
  type SchedulerToolContext,
22
23
  } from "../tool-support";
23
24
 
@@ -27,74 +28,69 @@ export function createSlackScheduleCreateTaskTool(
27
28
  ) {
28
29
  return definePluginTool({
29
30
  description:
30
- "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. Do not create scheduled polling tasks to watch provider resources when a subscribable tool result can deliver the requested events. Only manage tasks for the active Slack DM or channel; never target threads, other channels, or another user's DM. Set credential_mode to creator only when the current user explicitly authorizes future scheduled use of their connected credentials. If connected-credential use is needed but authorization is ambiguous, ask before creating the task. Otherwise omit credential_mode and use system credentials. When the task, schedule, destination, and credential mode are clear, create it without another confirmation.",
31
+ "Create a one-time or recurring Junior task in the active Slack conversation when the user asks Junior to do work later or repeatedly.",
31
32
  executionMode: "sequential",
32
- inputSchema: z.object({
33
- task: z.string().min(1).max(4000),
34
- schedule: z.string().min(1).max(300),
35
- schedule_kind: z
36
- .enum(["one_off", "recurring"])
37
- .describe(
38
- "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.",
33
+ inputSchema: z
34
+ .object({
35
+ task: z.string().min(1).max(4000),
36
+ schedule: scheduleIntentSchema.describe(
37
+ "When the task runs. The scheduler computes the exact next run from this intent and the server clock.",
39
38
  ),
40
- timezone: z
41
- .string()
42
- .min(1)
43
- .max(80)
44
- .describe(
45
- "IANA timezone, e.g. 'America/Los_Angeles'. Defaults to the channel's configured timezone.",
46
- )
47
- .optional(),
48
- next_run_at: z
49
- .string()
50
- .min(1)
51
- .describe(
52
- "Exact next run time as an ISO timestamp, computed from the user's requested schedule.",
53
- )
54
- .optional(),
55
- recurrence: z
56
- .enum(["daily", "weekly", "monthly", "yearly"])
57
- .nullable()
58
- .describe(
59
- "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.",
60
- )
61
- .optional(),
62
- credential_mode: z
63
- .enum(["system", "creator"])
64
- .nullable()
65
- .describe(
66
- "Use creator only when the current user explicitly authorizes future scheduled use of their connected credentials. Omit or use system otherwise.",
67
- )
68
- .optional(),
69
- }),
39
+ credential_mode: z
40
+ .enum(["system", "creator"])
41
+ .nullable()
42
+ .describe(
43
+ "Use creator only when the current user explicitly authorizes future scheduled use of their connected credentials. Omit or use system otherwise.",
44
+ )
45
+ .optional(),
46
+ })
47
+ .strict(),
70
48
  outputSchema: scheduleTaskToolResultSchema,
71
- execute: async (input) => {
49
+ execute: async (input, options) => {
72
50
  const destination = requireActiveConversation(context);
73
51
  const actor = requireActor(context, destination);
74
-
75
- const nowMs = Date.now();
76
- const timezone = input.timezone ?? getDefaultScheduleTimezone();
77
- validateCreateScheduleKind(input);
78
- validateRecurringFrequencyLimit(input);
79
- if (!isValidTimeZone(timezone)) {
80
- throwToolInputError("timezone must be a valid IANA time zone.");
52
+ const store = schedulerStore(context);
53
+ const id = buildTaskId({
54
+ actor,
55
+ destination,
56
+ toolCallId: options.toolCallId,
57
+ });
58
+ // Replaying a durable tool call returns its original task instead of duplicating it.
59
+ const existing = await store.getTask(id);
60
+ if (existing) {
61
+ if (
62
+ !sameDestination(existing, destination) ||
63
+ existing.createdBy.slackUserId !== actor.slackUserId
64
+ ) {
65
+ throwToolInputError("Scheduled task operation identity is invalid.");
66
+ }
67
+ return scheduleTaskToolResult(
68
+ "slackScheduleCreateTask",
69
+ compactTask(existing),
70
+ );
81
71
  }
82
- const nextRunAtMs = parseNextRunAtMs(input.next_run_at);
83
- if (!nextRunAtMs) {
84
- throwToolInputError("Provide next_run_at as a valid ISO timestamp.");
72
+
73
+ const nowMs = context.now?.() ?? Date.now();
74
+ let compiled;
75
+ try {
76
+ compiled = compileScheduleIntent({
77
+ defaultTimezone: getDefaultScheduleTimezone(),
78
+ intent: input.schedule,
79
+ nowMs,
80
+ });
81
+ } catch (error) {
82
+ if (error instanceof ScheduleIntentError) {
83
+ throwToolInputError(error.message);
84
+ }
85
+ throw error;
85
86
  }
86
- const recurrence = buildRecurrence({
87
- input,
88
- nextRunAtMs,
89
- timezone,
90
- });
91
87
  const conversationAccess = getConversationAccess(
92
88
  destination,
93
89
  context.source,
94
90
  );
95
91
 
96
92
  const task: ScheduledTask = {
97
- id: buildTaskId(),
93
+ id,
98
94
  createdAtMs: nowMs,
99
95
  updatedAtMs: nowMs,
100
96
  createdBy: actor,
@@ -102,24 +98,25 @@ export function createSlackScheduleCreateTaskTool(
102
98
  credentialMode: input.credential_mode ?? "system",
103
99
  destination,
104
100
  executionActor: SCHEDULED_TASK_SYSTEM_ACTOR,
105
- nextRunAtMs,
101
+ nextRunAtMs: compiled.nextRunAtMs,
106
102
  originalRequest: context.userText,
107
- schedule: {
108
- description: input.schedule,
109
- timezone,
110
- kind: recurrence ? "recurring" : "one_off",
111
- recurrence,
112
- },
103
+ schedule: compiled.schedule,
113
104
  status: "active",
114
105
  task: {
115
106
  text: input.task,
116
107
  },
117
108
  };
118
109
 
119
- await schedulerStore(context).saveTask(task);
110
+ const committed = await store.createTask(task);
111
+ if (
112
+ !sameDestination(committed, destination) ||
113
+ committed.createdBy.slackUserId !== actor.slackUserId
114
+ ) {
115
+ throwToolInputError("Scheduled task operation identity is invalid.");
116
+ }
120
117
  return scheduleTaskToolResult(
121
118
  "slackScheduleCreateTask",
122
- compactTask(task),
119
+ compactTask(committed),
123
120
  );
124
121
  },
125
122
  });
@@ -1,20 +1,21 @@
1
1
  import { definePluginTool } from "@sentry/junior-plugin-api";
2
2
  import { z } from "zod";
3
+ import {
4
+ compileScheduleIntent,
5
+ ScheduleIntentError,
6
+ scheduleIntentSchema,
7
+ } from "../schedule-intent";
3
8
  import type { ScheduledTask } from "../types";
4
9
  import {
5
- buildRecurrence,
6
10
  compactTask,
11
+ getDefaultScheduleTimezone,
7
12
  getWritableTask,
8
- isValidTimeZone,
9
13
  normalizeStatus,
10
- parseNextRunAtMs,
11
14
  requireActor,
12
15
  scheduleTaskToolResult,
13
16
  scheduleTaskToolResultSchema,
14
17
  schedulerStore,
15
- shouldRebuildRecurrence,
16
18
  throwToolInputError,
17
- validateRecurringFrequencyLimit,
18
19
  type SchedulerToolContext,
19
20
  } from "../tool-support";
20
21
 
@@ -24,44 +25,38 @@ export function createSlackScheduleUpdateTaskTool(
24
25
  ) {
25
26
  return definePluginTool({
26
27
  description:
27
- "Edit, pause, resume, reschedule, or change credential use for an existing Junior scheduled task in the active Slack conversation. Use only task IDs returned for this conversation. Do not move scheduled tasks across conversations. Set credential_mode to creator only when the task creator explicitly authorizes future scheduled use of their connected credentials. If creator credential use is requested but authorization is ambiguous, ask before enabling it. If another user changes the executable task text, creator credential use is cleared automatically.",
28
+ "Edit, pause, resume, reschedule, or change credential use for an existing Junior scheduled task in the active Slack conversation.",
28
29
  executionMode: "sequential",
29
- inputSchema: z.object({
30
- task_id: z
31
- .string()
32
- .min(1)
33
- .describe(
34
- "ID of the task to update. Must be from this active Slack conversation.",
35
- ),
36
- task: z.string().min(1).max(4000).optional(),
37
- schedule: z.string().min(1).max(300).optional(),
38
- timezone: z.string().min(1).max(80).optional(),
39
- next_run_at: z
40
- .string()
41
- .min(1)
42
- .describe("Exact ISO timestamp when changing the next run time.")
43
- .optional(),
44
- recurrence: z
45
- .enum(["daily", "weekly", "monthly", "yearly"])
46
- .nullable()
47
- .describe(
48
- "Provide only for repeating schedules. Omit for one-time requests. Set to null to convert a recurring task to one-time.",
49
- )
50
- .optional(),
51
- status: z
52
- .enum(["active", "paused", "blocked"])
53
- .describe(
54
- "Set to active, paused, or blocked to resume, pause, or block the task.",
55
- )
56
- .optional(),
57
- credential_mode: z
58
- .enum(["system", "creator"])
59
- .nullable()
60
- .describe(
61
- "Set creator only when the current actor is the task creator and explicitly authorizes future scheduled credential use. Set system to disable delegation.",
62
- )
63
- .optional(),
64
- }),
30
+ inputSchema: z
31
+ .object({
32
+ task_id: z
33
+ .string()
34
+ .min(1)
35
+ .describe(
36
+ "ID of the task to update. Must be from this active Slack conversation.",
37
+ ),
38
+ task: z.string().min(1).max(4000).optional(),
39
+ schedule: scheduleIntentSchema
40
+ .describe(
41
+ "Complete replacement schedule when rescheduling. Omit for task, status, or credential-only changes; the scheduler computes the next run.",
42
+ )
43
+ .nullable()
44
+ .optional(),
45
+ status: z
46
+ .enum(["active", "paused", "blocked"])
47
+ .describe(
48
+ "Set to active, paused, or blocked to resume, pause, or block the task.",
49
+ )
50
+ .optional(),
51
+ credential_mode: z
52
+ .enum(["system", "creator"])
53
+ .nullable()
54
+ .describe(
55
+ "Set creator only when the current actor is the task creator and explicitly authorizes future scheduled credential use. Set system to disable delegation.",
56
+ )
57
+ .optional(),
58
+ })
59
+ .strict(),
65
60
  outputSchema: scheduleTaskToolResultSchema,
66
61
  execute: async (input) => {
67
62
  const lookup = await getWritableTask({
@@ -76,18 +71,24 @@ export function createSlackScheduleUpdateTaskTool(
76
71
  );
77
72
  }
78
73
 
79
- const timezone = input.timezone ?? lookup.schedule.timezone;
80
- validateRecurringFrequencyLimit(input);
81
- if (!isValidTimeZone(timezone)) {
82
- throwToolInputError("timezone must be a valid IANA time zone.");
83
- }
84
- const parsedNextRunAtMs = parseNextRunAtMs(input.next_run_at);
85
- const nextRunAtMs = input.next_run_at
86
- ? parsedNextRunAtMs
87
- : lookup.nextRunAtMs;
88
- if (input.next_run_at && !nextRunAtMs) {
89
- throwToolInputError("Provide next_run_at as a valid ISO timestamp.");
74
+ const nowMs = context.now?.() ?? Date.now();
75
+ let compiled;
76
+ if (input.schedule) {
77
+ try {
78
+ compiled = compileScheduleIntent({
79
+ defaultTimezone:
80
+ lookup.schedule.timezone || getDefaultScheduleTimezone(),
81
+ intent: input.schedule,
82
+ nowMs,
83
+ });
84
+ } catch (error) {
85
+ if (error instanceof ScheduleIntentError) {
86
+ throwToolInputError(error.message);
87
+ }
88
+ throw error;
89
+ }
90
90
  }
91
+ const nextRunAtMs = compiled?.nextRunAtMs ?? lookup.nextRunAtMs;
91
92
 
92
93
  const status = normalizeStatus(input.status);
93
94
  if (input.status && !status) {
@@ -95,17 +96,9 @@ export function createSlackScheduleUpdateTaskTool(
95
96
  }
96
97
  if (status === "active" && !nextRunAtMs) {
97
98
  throwToolInputError(
98
- "Active scheduled tasks require next_run_at when no next run is stored.",
99
+ "Active scheduled tasks require a schedule with a future occurrence.",
99
100
  );
100
101
  }
101
- const recurrence = shouldRebuildRecurrence(input)
102
- ? buildRecurrence({
103
- existing: lookup.schedule.recurrence,
104
- input,
105
- nextRunAtMs,
106
- timezone,
107
- })
108
- : lookup.schedule.recurrence;
109
102
  const nextStatus = status ?? lookup.status;
110
103
  // Another actor changing executable text revokes creator delegation.
111
104
  const credentialMode =
@@ -118,19 +111,14 @@ export function createSlackScheduleUpdateTaskTool(
118
111
  const next: ScheduledTask = {
119
112
  ...lookup,
120
113
  credentialMode,
121
- updatedAtMs: Date.now(),
114
+ updatedAtMs: nowMs,
122
115
  nextRunAtMs,
123
- runNowAtMs: nextStatus === "active" ? lookup.runNowAtMs : undefined,
116
+ runNowAtMs:
117
+ nextStatus === "active" && !compiled ? lookup.runNowAtMs : undefined,
124
118
  status: nextStatus,
125
119
  statusReason:
126
120
  nextStatus === "blocked" ? lookup.statusReason : undefined,
127
- schedule: {
128
- ...lookup.schedule,
129
- description: input.schedule ?? lookup.schedule.description,
130
- timezone,
131
- kind: recurrence ? "recurring" : "one_off",
132
- recurrence,
133
- },
121
+ schedule: compiled?.schedule ?? lookup.schedule,
134
122
  task: input.task ? { text: input.task } : lookup.task,
135
123
  };
136
124