@sentry/junior-scheduler 0.107.1 → 0.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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