@sentry/junior-scheduler 0.101.0 → 0.102.1

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/src/store.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  pluginCredentialSubjectSchema,
3
3
  destinationSchema,
4
4
  isSlackDestination,
5
+ slackActorSchema,
5
6
  type PluginReadState,
6
7
  type PluginState,
7
8
  } from "@sentry/junior-plugin-api";
@@ -22,7 +23,11 @@ import { z } from "zod";
22
23
  import { getNextRunAtMs } from "./cadence";
23
24
  import * as schedulerSqlSchema from "./db/schema";
24
25
  import { juniorSchedulerRuns, juniorSchedulerTasks } from "./db/schema";
25
- import type { ScheduledRun, ScheduledTask } from "./types";
26
+ import {
27
+ scheduledTaskCredentialModeSchema,
28
+ type ScheduledRun,
29
+ type ScheduledTask,
30
+ } from "./types";
26
31
 
27
32
  const SCHEDULER_KEY_PREFIX = "junior:scheduler";
28
33
  const SCHEDULER_RECORD_TTL_MS = 5 * 365 * 24 * 60 * 60 * 1000;
@@ -39,7 +44,7 @@ export type SchedulerDb = PgDatabase<
39
44
  const slackDestinationSchema = destinationSchema.refine(isSlackDestination);
40
45
  const taskPrincipalSchema = z
41
46
  .object({
42
- slackUserId: z.string(),
47
+ slackUserId: slackActorSchema.shape.userId,
43
48
  fullName: z.string().optional(),
44
49
  userName: z.string().optional(),
45
50
  })
@@ -73,37 +78,47 @@ const taskSpecSchema = z
73
78
  text: z.string(),
74
79
  })
75
80
  .strict();
81
+ const taskRecordFields = {
82
+ id: z.string(),
83
+ conversationAccess: z
84
+ .object({
85
+ audience: z.enum(["direct", "group", "channel"]),
86
+ visibility: z.enum(["private", "public", "unknown"]),
87
+ })
88
+ .strict()
89
+ .optional(),
90
+ createdAtMs: z.number(),
91
+ createdBy: taskPrincipalSchema,
92
+ destination: slackDestinationSchema,
93
+ executionActor: z
94
+ .object({
95
+ platform: z.literal("system"),
96
+ name: z.string(),
97
+ })
98
+ .strict()
99
+ .optional(),
100
+ lastRunAtMs: z.number().optional(),
101
+ nextRunAtMs: z.number().optional(),
102
+ originalRequest: z.string().optional(),
103
+ runNowAtMs: z.number().optional(),
104
+ schedule: taskScheduleSchema,
105
+ status: z.enum(["active", "paused", "blocked", "deleted"]),
106
+ statusReason: z.string().optional(),
107
+ task: taskSpecSchema,
108
+ updatedAtMs: z.number(),
109
+ version: z.number().optional(),
110
+ };
76
111
  const taskRecordSchema = z
77
112
  .object({
78
- id: z.string(),
79
- conversationAccess: z
80
- .object({
81
- audience: z.enum(["direct", "group", "channel"]),
82
- visibility: z.enum(["private", "public", "unknown"]),
83
- })
84
- .strict()
85
- .optional(),
86
- createdAtMs: z.number(),
87
- createdBy: taskPrincipalSchema,
113
+ ...taskRecordFields,
114
+ credentialMode: scheduledTaskCredentialModeSchema,
115
+ })
116
+ .strict();
117
+ // TODO(v0.101.0): Remove parsing for scheduler task records without credentialMode.
118
+ const legacyTaskRecordSchema = z
119
+ .object({
120
+ ...taskRecordFields,
88
121
  credentialSubject: pluginCredentialSubjectSchema.optional(),
89
- destination: slackDestinationSchema,
90
- executionActor: z
91
- .object({
92
- platform: z.literal("system"),
93
- name: z.string(),
94
- })
95
- .strict()
96
- .optional(),
97
- lastRunAtMs: z.number().optional(),
98
- nextRunAtMs: z.number().optional(),
99
- originalRequest: z.string().optional(),
100
- runNowAtMs: z.number().optional(),
101
- schedule: taskScheduleSchema,
102
- status: z.enum(["active", "paused", "blocked", "deleted"]),
103
- statusReason: z.string().optional(),
104
- task: taskSpecSchema,
105
- updatedAtMs: z.number(),
106
- version: z.number().optional(),
107
122
  })
108
123
  .strict();
109
124
  const runRecordSchema = z
@@ -431,6 +446,9 @@ function normalizedText(value: string | undefined): string {
431
446
 
432
447
  function taskDedupeFingerprint(task: ScheduledTask): string {
433
448
  return JSON.stringify({
449
+ credentialMode: task.credentialMode,
450
+ credentialUserId:
451
+ task.credentialMode === "creator" ? task.createdBy.slackUserId : null,
434
452
  destination: task.destination,
435
453
  schedule: {
436
454
  kind: task.schedule.kind,
@@ -475,6 +493,25 @@ function parseStoredTask(value: unknown): ScheduledTask | undefined {
475
493
  return parsed.success ? stripLegacyTaskFields(parsed.data) : undefined;
476
494
  }
477
495
 
496
+ /** Decode pre-credential-mode tasks only for the state-to-SQL migration. */
497
+ function parseLegacyStoredTaskForMigration(
498
+ value: unknown,
499
+ ): ScheduledTask | undefined {
500
+ const parsed = legacyTaskRecordSchema.safeParse(parseJsonRecord(value));
501
+ if (!parsed.success) {
502
+ return undefined;
503
+ }
504
+ const {
505
+ credentialSubject: _credentialSubject,
506
+ version: _version,
507
+ ...task
508
+ } = parsed.data;
509
+ return {
510
+ ...task,
511
+ credentialMode: "system",
512
+ };
513
+ }
514
+
478
515
  /** Decode retained scheduler run state, skipping invalid legacy records. */
479
516
  function parseStoredRun(value: unknown): ScheduledRun | undefined {
480
517
  const parsed = runRecordSchema.safeParse(parseJsonRecord(value));
@@ -1674,7 +1711,9 @@ export async function migrateSchedulerStateToSql(args: {
1674
1711
  const migratedTasks: ScheduledTask[] = [];
1675
1712
 
1676
1713
  for (const id of ids) {
1677
- const task = await getTaskFromState(args.state, id);
1714
+ const rawTask = await args.state.get(taskKey(id));
1715
+ const task =
1716
+ parseStoredTask(rawTask) ?? parseLegacyStoredTaskForMigration(rawTask);
1678
1717
  if (!task) {
1679
1718
  missing += 1;
1680
1719
  continue;
@@ -2,9 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import {
3
3
  PluginToolInputError,
4
4
  pluginToolResultSchema,
5
- pluginCredentialSubjectSchema,
6
5
  sourceSchema,
7
- type PluginCredentialSubject,
8
6
  type SlackDestination,
9
7
  type SlackActor,
10
8
  type SlackSource,
@@ -23,7 +21,6 @@ import type {
23
21
  } from "./types";
24
22
 
25
23
  export interface SchedulerToolContext {
26
- credentialSubject?: PluginCredentialSubject;
27
24
  actor?: SlackActor;
28
25
  source?: SlackSource;
29
26
  store: SchedulerStore;
@@ -44,7 +41,7 @@ const compactTaskResultSchema = z
44
41
  recurrence: z.unknown().nullable(),
45
42
  next_run_at: z.string().nullable(),
46
43
  conversation_access: z.unknown().nullable(),
47
- credential_subject: z.unknown().nullable(),
44
+ credential_mode: z.enum(["system", "creator"]),
48
45
  last_run_at: z.string().nullable(),
49
46
  run_now_at: z.string().nullable(),
50
47
  })
@@ -132,8 +129,12 @@ export function requireActiveConversation(
132
129
  /** Require a concrete Slack actor before creating scheduler ownership state. */
133
130
  export function requireActor(
134
131
  context: SchedulerToolContext,
132
+ destination: SlackDestination,
135
133
  ): ScheduledTaskPrincipal {
136
- if (context.actor?.platform !== "slack") {
134
+ if (
135
+ context.actor?.platform !== "slack" ||
136
+ context.actor.teamId !== destination.teamId
137
+ ) {
137
138
  throwToolInputError("No active Slack actor context is available.");
138
139
  }
139
140
  const userId = context.actor?.userId?.trim();
@@ -168,31 +169,6 @@ export function getConversationAccess(
168
169
  return { audience: "channel", visibility: "unknown" };
169
170
  }
170
171
 
171
- /** Carry private-DM credential authority only when scheduled work is allowed to reuse it. */
172
- export function getCredentialSubject(args: {
173
- access: ScheduledTaskConversationAccess;
174
- subject: PluginCredentialSubject | undefined;
175
- }): PluginCredentialSubject | undefined {
176
- if (
177
- args.access.audience !== "direct" ||
178
- args.access.visibility !== "private"
179
- ) {
180
- return undefined;
181
- }
182
- if (!args.subject) {
183
- return undefined;
184
- }
185
- const subject = pluginCredentialSubjectSchema.safeParse(args.subject);
186
- if (!subject.success) {
187
- throwToolInputError("Active Slack credential subject is invalid.");
188
- }
189
- return {
190
- type: subject.data.type,
191
- userId: subject.data.userId,
192
- allowedWhen: subject.data.allowedWhen,
193
- };
194
- }
195
-
196
172
  /** Keep scheduler management operations bound to the task's original Slack destination. */
197
173
  export function sameDestination(
198
174
  task: ScheduledTask,
@@ -251,12 +227,7 @@ export function compactTask(task: ScheduledTask): CompactTaskResult {
251
227
  ? new Date(task.nextRunAtMs).toISOString()
252
228
  : null,
253
229
  conversation_access: task.conversationAccess ?? null,
254
- credential_subject: task.credentialSubject
255
- ? {
256
- type: task.credentialSubject.type,
257
- allowed_when: task.credentialSubject.allowedWhen,
258
- }
259
- : null,
230
+ credential_mode: task.credentialMode,
260
231
  last_run_at: task.lastRunAtMs
261
232
  ? new Date(task.lastRunAtMs).toISOString()
262
233
  : null,
@@ -7,7 +7,6 @@ import {
7
7
  buildTaskId,
8
8
  compactTask,
9
9
  getConversationAccess,
10
- getCredentialSubject,
11
10
  getDefaultScheduleTimezone,
12
11
  isValidTimeZone,
13
12
  parseNextRunAtMs,
@@ -28,7 +27,7 @@ export function createSlackScheduleCreateTaskTool(
28
27
  ) {
29
28
  return definePluginTool({
30
29
  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. 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. When the task, schedule, and destination are clear, create it without asking for confirmation; ask only when one of those is ambiguous.",
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.",
32
31
  executionMode: "sequential",
33
32
  inputSchema: z.object({
34
33
  task: z.string().min(1).max(4000),
@@ -60,11 +59,18 @@ export function createSlackScheduleCreateTaskTool(
60
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.",
61
60
  )
62
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(),
63
69
  }),
64
70
  outputSchema: scheduleTaskToolResultSchema,
65
71
  execute: async (input) => {
66
72
  const destination = requireActiveConversation(context);
67
- const actor = requireActor(context);
73
+ const actor = requireActor(context, destination);
68
74
 
69
75
  const nowMs = Date.now();
70
76
  const timezone = input.timezone ?? getDefaultScheduleTimezone();
@@ -83,10 +89,6 @@ export function createSlackScheduleCreateTaskTool(
83
89
  timezone,
84
90
  });
85
91
  const conversationAccess = getConversationAccess(destination);
86
- const credentialSubject = getCredentialSubject({
87
- access: conversationAccess,
88
- subject: context.credentialSubject,
89
- });
90
92
 
91
93
  const task: ScheduledTask = {
92
94
  id: buildTaskId(),
@@ -94,7 +96,7 @@ export function createSlackScheduleCreateTaskTool(
94
96
  updatedAtMs: nowMs,
95
97
  createdBy: actor,
96
98
  conversationAccess,
97
- ...(credentialSubject ? { credentialSubject } : {}),
99
+ credentialMode: input.credential_mode ?? "system",
98
100
  destination,
99
101
  executionActor: SCHEDULED_TASK_SYSTEM_ACTOR,
100
102
  nextRunAtMs,
@@ -8,6 +8,7 @@ import {
8
8
  isValidTimeZone,
9
9
  normalizeStatus,
10
10
  parseNextRunAtMs,
11
+ requireActor,
11
12
  scheduleTaskToolResult,
12
13
  scheduleTaskToolResultSchema,
13
14
  schedulerStore,
@@ -23,7 +24,7 @@ export function createSlackScheduleUpdateTaskTool(
23
24
  ) {
24
25
  return definePluginTool({
25
26
  description:
26
- "Edit, pause, resume, or reschedule 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.",
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.",
27
28
  executionMode: "sequential",
28
29
  inputSchema: z.object({
29
30
  task_id: z
@@ -53,6 +54,13 @@ export function createSlackScheduleUpdateTaskTool(
53
54
  "Set to active, paused, or blocked to resume, pause, or block the task.",
54
55
  )
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(),
56
64
  }),
57
65
  outputSchema: scheduleTaskToolResultSchema,
58
66
  execute: async (input) => {
@@ -60,6 +68,13 @@ export function createSlackScheduleUpdateTaskTool(
60
68
  context,
61
69
  taskId: input.task_id,
62
70
  });
71
+ const actor = requireActor(context, lookup.destination);
72
+ const isCreator = actor.slackUserId === lookup.createdBy.slackUserId;
73
+ if (input.credential_mode === "creator" && !isCreator) {
74
+ throwToolInputError(
75
+ "Only the scheduled task creator can enable creator credential use.",
76
+ );
77
+ }
63
78
 
64
79
  const timezone = input.timezone ?? lookup.schedule.timezone;
65
80
  validateRecurringFrequencyLimit(input);
@@ -92,9 +107,17 @@ export function createSlackScheduleUpdateTaskTool(
92
107
  })
93
108
  : lookup.schedule.recurrence;
94
109
  const nextStatus = status ?? lookup.status;
110
+ // Another actor changing executable text revokes creator delegation.
111
+ const credentialMode =
112
+ input.task !== undefined &&
113
+ input.task !== lookup.task.text &&
114
+ !isCreator
115
+ ? "system"
116
+ : (input.credential_mode ?? lookup.credentialMode);
95
117
 
96
118
  const next: ScheduledTask = {
97
119
  ...lookup,
120
+ credentialMode,
98
121
  updatedAtMs: Date.now(),
99
122
  nextRunAtMs,
100
123
  runNowAtMs: nextStatus === "active" ? lookup.runNowAtMs : undefined,
package/src/types.ts CHANGED
@@ -1,10 +1,11 @@
1
- import type {
2
- PluginCredentialSubject,
3
- SlackDestination,
4
- SystemActor,
5
- } from "@sentry/junior-plugin-api";
1
+ import type { SlackDestination, SystemActor } from "@sentry/junior-plugin-api";
2
+ import { z } from "zod";
6
3
 
7
4
  export type ScheduledTaskStatus = "active" | "paused" | "blocked" | "deleted";
5
+ export const scheduledTaskCredentialModeSchema = z.enum(["system", "creator"]);
6
+ export type ScheduledTaskCredentialMode = z.infer<
7
+ typeof scheduledTaskCredentialModeSchema
8
+ >;
8
9
 
9
10
  export type ScheduledRunStatus =
10
11
  | "pending"
@@ -69,7 +70,8 @@ export interface ScheduledTask {
69
70
  createdAtMs: number;
70
71
  createdBy: ScheduledTaskPrincipal;
71
72
  conversationAccess?: ScheduledTaskConversationAccess;
72
- credentialSubject?: PluginCredentialSubject;
73
+ /** Selects system credentials or explicitly delegated creator credentials. */
74
+ credentialMode: ScheduledTaskCredentialMode;
73
75
  destination: SlackDestination;
74
76
  executionActor?: ScheduledTaskExecutionActor;
75
77
  lastRunAtMs?: number;