@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.
package/src/plugin.ts ADDED
@@ -0,0 +1,312 @@
1
+ import {
2
+ defineJuniorPlugin,
3
+ type Dispatch,
4
+ type AgentPluginToolDefinition,
5
+ type ToolRegistrationHookContext,
6
+ } from "@sentry/junior-plugin-api";
7
+ import { buildScheduledTaskRunPrompt } from "./prompt";
8
+ import { createSchedulerStore, type SchedulerStore } from "./store";
9
+ import type { ScheduledRun, ScheduledTask } from "./types";
10
+ import {
11
+ createSlackScheduleCreateTaskTool,
12
+ createSlackScheduleDeleteTaskTool,
13
+ createSlackScheduleListTasksTool,
14
+ createSlackScheduleRunTaskNowTool,
15
+ createSlackScheduleUpdateTaskTool,
16
+ type SchedulerToolContext,
17
+ } from "./schedule-tools";
18
+
19
+ const SCHEDULER_HEARTBEAT_LIMIT = 10;
20
+
21
+ function shouldSkipRun(
22
+ task: ScheduledTask,
23
+ run: ScheduledRun,
24
+ ): string | undefined {
25
+ if (task.status === "deleted") {
26
+ return `Scheduled task ${task.id} was deleted before the run started.`;
27
+ }
28
+ if (task.status !== "active") {
29
+ return `Scheduled task ${task.id} was ${task.status} before the run started.`;
30
+ }
31
+ if (
32
+ task.nextRunAtMs !== run.scheduledForMs &&
33
+ task.runNowAtMs !== run.scheduledForMs
34
+ ) {
35
+ return `Scheduled task ${task.id} no longer targets ${new Date(run.scheduledForMs).toISOString()}.`;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ function createSchedulerToolContext(
41
+ ctx: ToolRegistrationHookContext,
42
+ ): SchedulerToolContext {
43
+ return {
44
+ channelCapabilities: ctx.channelCapabilities ?? {
45
+ canAddReactions: false,
46
+ canCreateCanvas: false,
47
+ canPostToChannel: false,
48
+ },
49
+ channelId: ctx.channelId,
50
+ messageTs: ctx.messageTs,
51
+ requester: ctx.requester,
52
+ state: ctx.state,
53
+ teamId: ctx.teamId,
54
+ threadTs: ctx.threadTs,
55
+ userText: ctx.userText,
56
+ };
57
+ }
58
+
59
+ async function applyDispatchResult(args: {
60
+ dispatch: Dispatch;
61
+ nowMs: number;
62
+ run: ScheduledRun;
63
+ store: ReturnType<typeof createSchedulerStore>;
64
+ }): Promise<boolean> {
65
+ if (args.dispatch.status === "completed") {
66
+ const completed = await args.store.markRunCompleted({
67
+ completedAtMs: args.nowMs,
68
+ resultMessageTs: args.dispatch.resultMessageTs,
69
+ runId: args.run.id,
70
+ startedAtMs: args.run.startedAtMs!,
71
+ });
72
+ if (!completed) {
73
+ return false;
74
+ }
75
+ await args.store.updateTaskAfterRun({
76
+ nowMs: args.nowMs,
77
+ run: args.run,
78
+ status: "completed",
79
+ });
80
+ return true;
81
+ }
82
+
83
+ if (args.dispatch.status === "blocked") {
84
+ const blocked = await args.store.markRunBlocked({
85
+ completedAtMs: args.nowMs,
86
+ errorMessage: args.dispatch.errorMessage ?? "Dispatch blocked.",
87
+ runId: args.run.id,
88
+ startedAtMs: args.run.startedAtMs!,
89
+ });
90
+ if (!blocked) {
91
+ return false;
92
+ }
93
+ await args.store.updateTaskAfterRun({
94
+ errorMessage: blocked.errorMessage,
95
+ nowMs: args.nowMs,
96
+ run: args.run,
97
+ status: "blocked",
98
+ });
99
+ return true;
100
+ }
101
+
102
+ if (args.dispatch.status === "failed") {
103
+ const failed = await args.store.markRunFailed({
104
+ completedAtMs: args.nowMs,
105
+ errorMessage: args.dispatch.errorMessage ?? "Dispatch failed.",
106
+ runId: args.run.id,
107
+ startedAtMs: args.run.startedAtMs,
108
+ });
109
+ if (!failed) {
110
+ return false;
111
+ }
112
+ await args.store.updateTaskAfterRun({
113
+ errorMessage: failed.errorMessage,
114
+ nowMs: args.nowMs,
115
+ run: args.run,
116
+ status: "failed",
117
+ });
118
+ return true;
119
+ }
120
+
121
+ return false;
122
+ }
123
+
124
+ async function blockClaimedRun(args: {
125
+ errorMessage: string;
126
+ nowMs: number;
127
+ run: ScheduledRun;
128
+ store: SchedulerStore;
129
+ }): Promise<void> {
130
+ const blocked = await args.store.markRunBlocked({
131
+ completedAtMs: args.nowMs,
132
+ errorMessage: args.errorMessage,
133
+ runId: args.run.id,
134
+ });
135
+ if (!blocked) {
136
+ return;
137
+ }
138
+ await args.store.updateTaskAfterRun({
139
+ errorMessage: args.errorMessage,
140
+ nowMs: args.nowMs,
141
+ run: args.run,
142
+ status: "blocked",
143
+ });
144
+ }
145
+
146
+ async function failClaimedRun(args: {
147
+ errorMessage: string;
148
+ nowMs: number;
149
+ run: ScheduledRun;
150
+ store: SchedulerStore;
151
+ }): Promise<void> {
152
+ const failed = await args.store.markRunFailed({
153
+ completedAtMs: args.nowMs,
154
+ errorMessage: args.errorMessage,
155
+ runId: args.run.id,
156
+ startedAtMs: args.run.startedAtMs,
157
+ });
158
+ if (!failed) {
159
+ return;
160
+ }
161
+ await args.store.updateTaskAfterRun({
162
+ errorMessage: args.errorMessage,
163
+ nowMs: args.nowMs,
164
+ run: args.run,
165
+ status: "failed",
166
+ });
167
+ }
168
+
169
+ /** Create Junior's built-in trusted scheduler plugin. */
170
+ export function createSchedulerPlugin() {
171
+ return defineJuniorPlugin({
172
+ name: "scheduler",
173
+ pluginConfig: {
174
+ legacyStatePrefixes: ["junior:scheduler"],
175
+ packages: ["@sentry/junior-scheduler"],
176
+ },
177
+ hooks: {
178
+ tools(ctx) {
179
+ if (!ctx.channelId || !ctx.teamId || !ctx.requester?.userId) {
180
+ return {} as Record<string, AgentPluginToolDefinition<any>>;
181
+ }
182
+ const context = createSchedulerToolContext(ctx);
183
+ return {
184
+ slackScheduleCreateTask: createSlackScheduleCreateTaskTool(context),
185
+ slackScheduleListTasks: createSlackScheduleListTasksTool(context),
186
+ slackScheduleUpdateTask: createSlackScheduleUpdateTaskTool(context),
187
+ slackScheduleDeleteTask: createSlackScheduleDeleteTaskTool(context),
188
+ slackScheduleRunTaskNow: createSlackScheduleRunTaskNowTool(context),
189
+ } satisfies Record<string, AgentPluginToolDefinition<any>>;
190
+ },
191
+ async heartbeat(ctx) {
192
+ const store = createSchedulerStore(ctx.state);
193
+ let processedCount = 0;
194
+ let dispatchCount = 0;
195
+ for (const run of await store.listIncompleteRuns()) {
196
+ if (!run.dispatchId) {
197
+ continue;
198
+ }
199
+ const dispatch = await ctx.agent.get(run.dispatchId);
200
+ if (!dispatch) {
201
+ await failClaimedRun({
202
+ errorMessage: "Scheduled task dispatch record is missing.",
203
+ nowMs: ctx.nowMs,
204
+ run,
205
+ store,
206
+ });
207
+ continue;
208
+ }
209
+ if (
210
+ await applyDispatchResult({
211
+ dispatch,
212
+ nowMs: ctx.nowMs,
213
+ run,
214
+ store,
215
+ })
216
+ ) {
217
+ processedCount += 1;
218
+ }
219
+ }
220
+
221
+ for (
222
+ let index = processedCount;
223
+ index < SCHEDULER_HEARTBEAT_LIMIT;
224
+ index += 1
225
+ ) {
226
+ const run = await store.claimDueRun({ nowMs: ctx.nowMs });
227
+ if (!run) {
228
+ break;
229
+ }
230
+ const task = await store.getTask(run.taskId);
231
+ if (!task) {
232
+ await store.markRunFailed({
233
+ completedAtMs: ctx.nowMs,
234
+ errorMessage: `Scheduled task ${run.taskId} was not found`,
235
+ runId: run.id,
236
+ });
237
+ continue;
238
+ }
239
+ const skippedReason = shouldSkipRun(task, run);
240
+ if (skippedReason) {
241
+ await store.markRunSkipped({
242
+ completedAtMs: ctx.nowMs,
243
+ errorMessage: skippedReason,
244
+ runId: run.id,
245
+ });
246
+ continue;
247
+ }
248
+
249
+ let prompt: string;
250
+ try {
251
+ prompt = buildScheduledTaskRunPrompt({
252
+ nowMs: ctx.nowMs,
253
+ run,
254
+ task,
255
+ });
256
+ } catch (error) {
257
+ const errorMessage =
258
+ error instanceof Error
259
+ ? `Scheduled task prompt could not be built: ${error.message}`
260
+ : "Scheduled task prompt could not be built.";
261
+ await blockClaimedRun({
262
+ errorMessage,
263
+ nowMs: ctx.nowMs,
264
+ run,
265
+ store,
266
+ });
267
+ continue;
268
+ }
269
+ let dispatch: Awaited<ReturnType<typeof ctx.agent.dispatch>>;
270
+ try {
271
+ dispatch = await ctx.agent.dispatch({
272
+ idempotencyKey: run.id,
273
+ ...(task.credentialSubject
274
+ ? { credentialSubject: task.credentialSubject }
275
+ : {}),
276
+ destination: task.destination,
277
+ input: prompt,
278
+ metadata: {
279
+ runId: run.id,
280
+ taskId: task.id,
281
+ },
282
+ });
283
+ } catch (error) {
284
+ const errorMessage =
285
+ error instanceof Error
286
+ ? `Scheduled task dispatch could not be created: ${error.message}`
287
+ : "Scheduled task dispatch could not be created.";
288
+ await blockClaimedRun({
289
+ errorMessage,
290
+ nowMs: ctx.nowMs,
291
+ run,
292
+ store,
293
+ });
294
+ continue;
295
+ }
296
+ await store.markRunDispatched({
297
+ claimedAtMs: run.claimedAtMs,
298
+ dispatchId: dispatch.id,
299
+ nowMs: ctx.nowMs,
300
+ runId: run.id,
301
+ });
302
+ dispatchCount += 1;
303
+ }
304
+
305
+ return { dispatchCount };
306
+ },
307
+ },
308
+ });
309
+ }
310
+
311
+ /** Register trusted scheduler runtime hooks for scheduled Junior tasks. */
312
+ export const schedulerPlugin = createSchedulerPlugin;
package/src/prompt.ts ADDED
@@ -0,0 +1,89 @@
1
+ import {
2
+ SCHEDULED_TASK_SYSTEM_ACTOR,
3
+ type ScheduledRun,
4
+ type ScheduledTask,
5
+ } from "./types";
6
+
7
+ function escapeXml(value: string): string {
8
+ return value
9
+ .replaceAll("&", "&amp;")
10
+ .replaceAll("<", "&lt;")
11
+ .replaceAll(">", "&gt;")
12
+ .replaceAll('"', "&quot;")
13
+ .replaceAll("'", "&apos;");
14
+ }
15
+
16
+ const EXECUTION_RULES = [
17
+ "- Execute as the scheduled-task system actor; creator metadata is audit context, not an active user identity.",
18
+ "- Complete the task without asking follow-up questions unless access, approval, or required input is missing.",
19
+ "- Use the available tools and skills that are relevant to the task contract.",
20
+ "- Do not create, edit, or discuss scheduling during this run; the stored schedule already fired.",
21
+ "- For reminder tasks, deliver the reminder message now instead of explaining how reminders or delayed posts work.",
22
+ "- If blocked, report the specific missing provider, permission, configuration, or input.",
23
+ "- Keep the final result shaped for the configured destination audience.",
24
+ ];
25
+
26
+ function renderOptionalLine(name: string, value: string | undefined): string[] {
27
+ return value?.trim() ? [`- ${name}: ${escapeXml(value.trim())}`] : [];
28
+ }
29
+
30
+ /** Build the marker-delimited user prompt for one scheduled task execution. */
31
+ export function buildScheduledTaskRunPrompt(args: {
32
+ nowMs: number;
33
+ run: ScheduledRun;
34
+ task: ScheduledTask;
35
+ }): string {
36
+ const { run, task } = args;
37
+ const destination = task.destination;
38
+ const creator = task.createdBy;
39
+ const executionActor = task.executionActor ?? SCHEDULED_TASK_SYSTEM_ACTOR;
40
+ if (!task.task.text?.trim()) {
41
+ throw new Error("Scheduled task text is required");
42
+ }
43
+
44
+ return [
45
+ "<scheduled-task-run>",
46
+ "This is an autonomous scheduled run. Treat the stored task contract as the user request for this turn.",
47
+ "",
48
+ "<scheduled-task>",
49
+ `- id: ${escapeXml(task.id)}`,
50
+ "<task-text>",
51
+ escapeXml(task.task.text),
52
+ "</task-text>",
53
+ "</scheduled-task>",
54
+ "",
55
+ "<run-context>",
56
+ `- run_id: ${escapeXml(run.id)}`,
57
+ `- task_version: ${run.taskVersion}`,
58
+ `- scheduled_for: ${new Date(run.scheduledForMs).toISOString()}`,
59
+ `- running_at: ${new Date(args.nowMs).toISOString()}`,
60
+ `- schedule: ${escapeXml(task.schedule.description)}`,
61
+ `- timezone: ${escapeXml(task.schedule.timezone)}`,
62
+ `- schedule_kind: ${task.schedule.kind}`,
63
+ `- execution_actor_type: ${executionActor.type}`,
64
+ `- execution_actor_id: ${escapeXml(executionActor.id)}`,
65
+ ...(task.schedule.recurrence
66
+ ? [
67
+ `- recurrence_frequency: ${task.schedule.recurrence.frequency}`,
68
+ `- recurrence_interval: ${task.schedule.recurrence.interval}`,
69
+ `- recurrence_start_date: ${escapeXml(task.schedule.recurrence.startDate)}`,
70
+ ]
71
+ : []),
72
+ `- creator_slack_user_id: ${escapeXml(creator.slackUserId)}`,
73
+ ...renderOptionalLine("creator_user_name", creator.userName),
74
+ ...renderOptionalLine("creator_full_name", creator.fullName),
75
+ `- destination_platform: ${destination.platform}`,
76
+ `- destination_team_id: ${escapeXml(destination.teamId)}`,
77
+ `- destination_channel_id: ${escapeXml(destination.channelId)}`,
78
+ "</run-context>",
79
+ "",
80
+ "<execution-rules>",
81
+ ...EXECUTION_RULES,
82
+ "</execution-rules>",
83
+ "",
84
+ '<current-instruction priority="highest">',
85
+ "Execute the scheduled task now and provide the final result for the configured destination.",
86
+ "</current-instruction>",
87
+ "</scheduled-task-run>",
88
+ ].join("\n");
89
+ }