@sentry/junior-scheduler 0.100.0 → 0.102.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/README.md +52 -0
- package/dist/index.js +180 -150
- package/dist/store.d.ts +1 -1
- package/dist/tool-support.d.ts +26 -17
- package/dist/tools/create-task.d.ts +3 -2
- package/dist/tools/delete-task.d.ts +2 -2
- package/dist/tools/list-tasks.d.ts +2 -2
- package/dist/tools/run-task-now.d.ts +2 -2
- package/dist/tools/update-task.d.ts +3 -2
- package/dist/types.d.ts +9 -2
- package/migrations/0001_explicit_credential_mode.sql +4 -0
- package/migrations/meta/0001_snapshot.json +251 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -2
- package/src/plugin.ts +20 -4
- package/src/store.ts +70 -31
- package/src/tool-support.ts +7 -36
- package/src/tools/create-task.ts +10 -8
- package/src/tools/update-task.ts +24 -1
- package/src/types.ts +8 -6
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
|
|
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:
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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;
|
package/src/tool-support.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
package/src/tools/create-task.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
99
|
+
credentialMode: input.credential_mode ?? "system",
|
|
98
100
|
destination,
|
|
99
101
|
executionActor: SCHEDULED_TASK_SYSTEM_ACTOR,
|
|
100
102
|
nextRunAtMs,
|
package/src/tools/update-task.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
/** Selects system credentials or explicitly delegated creator credentials. */
|
|
74
|
+
credentialMode: ScheduledTaskCredentialMode;
|
|
73
75
|
destination: SlackDestination;
|
|
74
76
|
executionActor?: ScheduledTaskExecutionActor;
|
|
75
77
|
lastRunAtMs?: number;
|