@sentry/junior-scheduler 0.74.1 → 0.76.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/store.ts CHANGED
@@ -1,11 +1,27 @@
1
1
  import {
2
- agentPluginCredentialSubjectSchema,
2
+ pluginCredentialSubjectSchema,
3
3
  destinationSchema,
4
4
  isSlackDestination,
5
- type AgentPluginReadState,
6
- type AgentPluginState,
5
+ type PluginReadState,
6
+ type PluginState,
7
7
  } from "@sentry/junior-plugin-api";
8
+ import {
9
+ and,
10
+ asc,
11
+ eq,
12
+ inArray,
13
+ isNotNull,
14
+ lte,
15
+ ne,
16
+ or,
17
+ sql,
18
+ } from "drizzle-orm";
19
+ import type { PgDatabase } from "drizzle-orm/pg-core";
20
+ import type { PgQueryResultHKT } from "drizzle-orm/pg-core/session";
21
+ import { z } from "zod";
8
22
  import { getNextRunAtMs } from "./cadence";
23
+ import * as schedulerSqlSchema from "./db/schema";
24
+ import { juniorSchedulerRuns, juniorSchedulerTasks } from "./db/schema";
9
25
  import type { ScheduledRun, ScheduledTask } from "./types";
10
26
 
11
27
  const SCHEDULER_KEY_PREFIX = "junior:scheduler";
@@ -15,6 +31,105 @@ const CLAIM_TTL_MS = 6 * 60 * 60 * 1000;
15
31
  const PENDING_CLAIM_STALE_MS = 60_000;
16
32
  const MISSED_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
17
33
  const LOCK_TTL_MS = 10_000;
34
+ const SQL_INCOMPLETE_RUN_STATUSES = ["pending", "running"] as const;
35
+ export type SchedulerDb = PgDatabase<
36
+ PgQueryResultHKT,
37
+ typeof schedulerSqlSchema
38
+ >;
39
+ const slackDestinationSchema = destinationSchema.refine(isSlackDestination);
40
+ const taskPrincipalSchema = z
41
+ .object({
42
+ slackUserId: z.string(),
43
+ fullName: z.string().optional(),
44
+ userName: z.string().optional(),
45
+ })
46
+ .strict();
47
+ const recurrenceSchema = z
48
+ .object({
49
+ dayOfMonth: z.number().optional(),
50
+ frequency: z.enum(["daily", "weekly", "monthly", "yearly"]),
51
+ interval: z.number(),
52
+ month: z.number().optional(),
53
+ startDate: z.string(),
54
+ time: z
55
+ .object({
56
+ hour: z.number(),
57
+ minute: z.number(),
58
+ })
59
+ .strict(),
60
+ weekdays: z.array(z.number()).optional(),
61
+ })
62
+ .strict();
63
+ const taskScheduleSchema = z
64
+ .object({
65
+ description: z.string(),
66
+ kind: z.enum(["one_off", "recurring"]),
67
+ recurrence: recurrenceSchema.optional(),
68
+ timezone: z.string(),
69
+ })
70
+ .strict();
71
+ const taskSpecSchema = z
72
+ .object({
73
+ text: z.string(),
74
+ })
75
+ .strict();
76
+ const taskRecordSchema = z
77
+ .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,
88
+ credentialSubject: pluginCredentialSubjectSchema.optional(),
89
+ destination: slackDestinationSchema,
90
+ executionActor: z
91
+ .object({
92
+ type: z.literal("system"),
93
+ id: 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
+ })
108
+ .strict();
109
+ const runRecordSchema = z
110
+ .object({
111
+ id: z.string(),
112
+ attempt: z.number(),
113
+ claimedAtMs: z.number(),
114
+ completedAtMs: z.number().optional(),
115
+ dispatchId: z.string().optional(),
116
+ errorMessage: z.string().optional(),
117
+ idempotencyKey: z.string().optional(),
118
+ resultMessageTs: z.string().optional(),
119
+ scheduledForMs: z.number(),
120
+ startedAtMs: z.number().optional(),
121
+ status: z.enum([
122
+ "pending",
123
+ "running",
124
+ "completed",
125
+ "failed",
126
+ "blocked",
127
+ "skipped",
128
+ ]),
129
+ taskId: z.string(),
130
+ taskVersion: z.number().optional(),
131
+ })
132
+ .strict();
18
133
 
19
134
  export interface SchedulerStore {
20
135
  claimDueRun(args: { nowMs: number }): Promise<ScheduledRun | undefined>;
@@ -107,7 +222,7 @@ function unique(values: string[]): string[] {
107
222
  }
108
223
 
109
224
  async function withLock<T>(
110
- state: AgentPluginState,
225
+ state: PluginState,
111
226
  key: string,
112
227
  callback: () => Promise<T>,
113
228
  ): Promise<T> {
@@ -115,7 +230,7 @@ async function withLock<T>(
115
230
  }
116
231
 
117
232
  async function addToIndex(
118
- state: AgentPluginState,
233
+ state: PluginState,
119
234
  key: string,
120
235
  taskId: string,
121
236
  ): Promise<void> {
@@ -128,7 +243,7 @@ async function addToIndex(
128
243
  }
129
244
 
130
245
  async function removeFromIndex(
131
- state: AgentPluginState,
246
+ state: PluginState,
132
247
  key: string,
133
248
  taskId: string,
134
249
  ): Promise<void> {
@@ -151,7 +266,7 @@ async function removeFromIndex(
151
266
  }
152
267
 
153
268
  async function getIndex(
154
- state: AgentPluginReadState,
269
+ state: PluginReadState,
155
270
  key: string,
156
271
  ): Promise<string[]> {
157
272
  const values = (await state.get<string[]>(key)) ?? [];
@@ -161,7 +276,7 @@ async function getIndex(
161
276
  }
162
277
 
163
278
  async function clearActiveRun(
164
- state: AgentPluginState,
279
+ state: PluginState,
165
280
  taskId: string,
166
281
  runId: string,
167
282
  ): Promise<void> {
@@ -174,7 +289,7 @@ async function clearActiveRun(
174
289
  }
175
290
 
176
291
  async function clearStaleActiveRun(
177
- state: AgentPluginState,
292
+ state: PluginState,
178
293
  taskId: string,
179
294
  nowMs: number,
180
295
  ): Promise<boolean> {
@@ -188,8 +303,7 @@ async function clearStaleActiveRun(
188
303
  return true;
189
304
  }
190
305
 
191
- const activeRun =
192
- (await state.get<ScheduledRun>(runKey(active.runId))) ?? undefined;
306
+ const activeRun = parseStoredRun(await state.get(runKey(active.runId)));
193
307
  if (!isStaleActiveRun(active, activeRun, nowMs)) {
194
308
  return false;
195
309
  }
@@ -276,16 +390,13 @@ function buildScheduledRun(args: {
276
390
  scheduledForMs: number;
277
391
  task: ScheduledTask;
278
392
  }): ScheduledRun {
279
- const idempotencyKey = `${args.task.id}:${args.scheduledForMs}`;
280
393
  return {
281
394
  id: buildRunId(args.task.id, args.scheduledForMs),
282
395
  attempt: 1,
283
396
  claimedAtMs: args.claimedAtMs,
284
- idempotencyKey,
285
397
  scheduledForMs: args.scheduledForMs,
286
398
  status: "pending",
287
399
  taskId: args.task.id,
288
- taskVersion: args.task.version,
289
400
  };
290
401
  }
291
402
 
@@ -358,27 +469,55 @@ function canFinishRun(
358
469
  return run.status === "running" && run.startedAtMs === startedAtMs;
359
470
  }
360
471
 
472
+ /** Decode retained scheduler task state, skipping invalid legacy records. */
361
473
  function parseStoredTask(value: unknown): ScheduledTask | undefined {
362
- if (!value || typeof value !== "object") {
363
- return undefined;
364
- }
365
- const record = value as Partial<ScheduledTask>;
366
- const destination = destinationSchema.safeParse(record.destination);
367
- if (!destination.success || !isSlackDestination(destination.data)) {
368
- return undefined;
474
+ const parsed = taskRecordSchema.safeParse(parseJsonRecord(value));
475
+ return parsed.success ? stripLegacyTaskFields(parsed.data) : undefined;
476
+ }
477
+
478
+ /** Decode retained scheduler run state, skipping invalid legacy records. */
479
+ function parseStoredRun(value: unknown): ScheduledRun | undefined {
480
+ const parsed = runRecordSchema.safeParse(parseJsonRecord(value));
481
+ return parsed.success ? stripLegacyRunFields(parsed.data) : undefined;
482
+ }
483
+
484
+ function stripLegacyTaskFields(
485
+ task: ScheduledTask & { version?: number },
486
+ ): ScheduledTask {
487
+ const { version: _version, ...current } = task;
488
+ return current;
489
+ }
490
+
491
+ function stripLegacyRunFields(
492
+ run: ScheduledRun & {
493
+ idempotencyKey?: string;
494
+ taskVersion?: number;
495
+ },
496
+ ): ScheduledRun {
497
+ const {
498
+ idempotencyKey: _idempotencyKey,
499
+ taskVersion: _taskVersion,
500
+ ...current
501
+ } = run;
502
+ return current;
503
+ }
504
+
505
+ function parseJsonRecord<T>(value: unknown): T | undefined {
506
+ if (typeof value === "string") {
507
+ try {
508
+ return JSON.parse(value) as T;
509
+ } catch {
510
+ return undefined;
511
+ }
369
512
  }
370
- const credentialSubject =
371
- record.credentialSubject === undefined
372
- ? undefined
373
- : agentPluginCredentialSubjectSchema.safeParse(record.credentialSubject);
374
- if (credentialSubject && !credentialSubject.success) {
375
- return undefined;
513
+ if (value && typeof value === "object") {
514
+ return value as T;
376
515
  }
377
- return {
378
- ...(record as ScheduledTask),
379
- destination: destination.data,
380
- ...(credentialSubject ? { credentialSubject: credentialSubject.data } : {}),
381
- };
516
+ return undefined;
517
+ }
518
+
519
+ function present<T>(value: T | undefined): value is T {
520
+ return value !== undefined;
382
521
  }
383
522
 
384
523
  function requireStoredTask(task: ScheduledTask): ScheduledTask {
@@ -390,14 +529,14 @@ function requireStoredTask(task: ScheduledTask): ScheduledTask {
390
529
  }
391
530
 
392
531
  async function getTaskFromState(
393
- state: AgentPluginReadState,
532
+ state: PluginReadState,
394
533
  taskId: string,
395
534
  ): Promise<ScheduledTask | undefined> {
396
535
  return parseStoredTask(await state.get(taskKey(taskId)));
397
536
  }
398
537
 
399
538
  async function listTasksFromState(
400
- state: AgentPluginReadState,
539
+ state: PluginReadState,
401
540
  indexKey: string,
402
541
  ): Promise<ScheduledTask[]> {
403
542
  const ids = await getIndex(state, indexKey);
@@ -409,14 +548,14 @@ async function listTasksFromState(
409
548
  }
410
549
 
411
550
  async function getRunFromState(
412
- state: AgentPluginReadState,
551
+ state: PluginReadState,
413
552
  runId: string,
414
553
  ): Promise<ScheduledRun | undefined> {
415
- return (await state.get<ScheduledRun>(runKey(runId))) ?? undefined;
554
+ return parseStoredRun(await state.get(runKey(runId)));
416
555
  }
417
556
 
418
557
  async function listIncompleteRunsForTasksFromState(
419
- state: AgentPluginReadState,
558
+ state: PluginReadState,
420
559
  tasks: ScheduledTask[],
421
560
  ): Promise<ScheduledRun[]> {
422
561
  const runs: ScheduledRun[] = [];
@@ -434,9 +573,9 @@ async function listIncompleteRunsForTasksFromState(
434
573
  }
435
574
 
436
575
  class PluginStateSchedulerOperationalStore implements SchedulerOperationalStore {
437
- private readonly state: AgentPluginReadState;
576
+ private readonly state: PluginReadState;
438
577
 
439
- constructor(state: AgentPluginReadState) {
578
+ constructor(state: PluginReadState) {
440
579
  this.state = state;
441
580
  }
442
581
 
@@ -452,9 +591,9 @@ class PluginStateSchedulerOperationalStore implements SchedulerOperationalStore
452
591
  }
453
592
 
454
593
  class PluginStateSchedulerStore implements SchedulerStore {
455
- private readonly state: AgentPluginState;
594
+ private readonly state: PluginState;
456
595
 
457
- constructor(state: AgentPluginState) {
596
+ constructor(state: PluginState) {
458
597
  this.state = state;
459
598
  }
460
599
 
@@ -648,7 +787,6 @@ class PluginStateSchedulerStore implements SchedulerStore {
648
787
  status: nextStatus,
649
788
  statusReason: nextStatus === "paused" ? errorMessage : undefined,
650
789
  updatedAtMs: args.nowMs,
651
- version: current.version + 1,
652
790
  },
653
791
  current,
654
792
  );
@@ -834,7 +972,6 @@ class PluginStateSchedulerStore implements SchedulerStore {
834
972
  statusReason:
835
973
  args.status === "blocked" ? args.errorMessage : undefined,
836
974
  updatedAtMs: args.nowMs,
837
- version: current.version + 1,
838
975
  },
839
976
  current,
840
977
  );
@@ -850,7 +987,6 @@ class PluginStateSchedulerStore implements SchedulerStore {
850
987
  ...current,
851
988
  lastRunAtMs: args.run.scheduledForMs,
852
989
  updatedAtMs: args.nowMs,
853
- version: current.version + 1,
854
990
  },
855
991
  current,
856
992
  );
@@ -876,7 +1012,6 @@ class PluginStateSchedulerStore implements SchedulerStore {
876
1012
  statusReason:
877
1013
  args.status === "blocked" ? args.errorMessage : undefined,
878
1014
  updatedAtMs: args.nowMs,
879
- version: current.version + 1,
880
1015
  },
881
1016
  current,
882
1017
  );
@@ -903,13 +1038,673 @@ class PluginStateSchedulerStore implements SchedulerStore {
903
1038
  }
904
1039
 
905
1040
  /** Create a scheduler store backed by this plugin's durable state namespace. */
906
- export function createSchedulerStore(state: AgentPluginState): SchedulerStore {
1041
+ export function createSchedulerStore(state: PluginState): SchedulerStore {
907
1042
  return new PluginStateSchedulerStore(state);
908
1043
  }
909
1044
 
910
1045
  /** Create a read-only scheduler store for operational reporting. */
911
1046
  export function createSchedulerOperationalStore(
912
- state: AgentPluginReadState,
1047
+ state: PluginReadState,
913
1048
  ): SchedulerOperationalStore {
914
1049
  return new PluginStateSchedulerOperationalStore(state);
915
1050
  }
1051
+
1052
+ type SchedulerTaskRow = {
1053
+ record: unknown;
1054
+ };
1055
+
1056
+ type SchedulerRunRow = {
1057
+ record: unknown;
1058
+ };
1059
+
1060
+ /** Decode scheduler SQL task records and reject rows unsafe for scan paths. */
1061
+ function parseSqlTaskRecord(value: unknown): ScheduledTask | undefined {
1062
+ const parsed = taskRecordSchema.safeParse(parseJsonRecord(value));
1063
+ return parsed.success ? stripLegacyTaskFields(parsed.data) : undefined;
1064
+ }
1065
+
1066
+ function parseSqlTaskRow(row: SchedulerTaskRow): ScheduledTask | undefined {
1067
+ return parseSqlTaskRecord(row.record);
1068
+ }
1069
+
1070
+ /** Decode scheduler SQL run records and reject rows unsafe for scan paths. */
1071
+ function parseSqlRunRow(row: SchedulerRunRow): ScheduledRun | undefined {
1072
+ const parsed = runRecordSchema.safeParse(parseJsonRecord(row.record));
1073
+ return parsed.success ? stripLegacyRunFields(parsed.data) : undefined;
1074
+ }
1075
+
1076
+ async function withSqlLock<T>(
1077
+ db: SchedulerDb,
1078
+ key: string,
1079
+ callback: (db: SchedulerDb) => Promise<T>,
1080
+ ): Promise<T> {
1081
+ return await db.transaction(async (tx) => {
1082
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${key}))`);
1083
+ return await callback(tx);
1084
+ });
1085
+ }
1086
+
1087
+ async function upsertSqlTask(
1088
+ db: SchedulerDb,
1089
+ task: ScheduledTask,
1090
+ ): Promise<void> {
1091
+ await db
1092
+ .insert(juniorSchedulerTasks)
1093
+ .values({
1094
+ createdAtMs: task.createdAtMs,
1095
+ id: task.id,
1096
+ nextRunAtMs: task.nextRunAtMs,
1097
+ record: task,
1098
+ runNowAtMs: task.runNowAtMs,
1099
+ status: task.status,
1100
+ teamId: task.destination.teamId,
1101
+ })
1102
+ .onConflictDoUpdate({
1103
+ target: juniorSchedulerTasks.id,
1104
+ set: {
1105
+ createdAtMs: sql`excluded.created_at_ms`,
1106
+ nextRunAtMs: sql`excluded.next_run_at_ms`,
1107
+ record: sql`excluded.record`,
1108
+ runNowAtMs: sql`excluded.run_now_at_ms`,
1109
+ status: sql`excluded.status`,
1110
+ teamId: sql`excluded.team_id`,
1111
+ },
1112
+ });
1113
+ }
1114
+
1115
+ async function upsertSqlRun(db: SchedulerDb, run: ScheduledRun): Promise<void> {
1116
+ await db
1117
+ .insert(juniorSchedulerRuns)
1118
+ .values({
1119
+ id: run.id,
1120
+ record: run,
1121
+ scheduledForMs: run.scheduledForMs,
1122
+ status: run.status,
1123
+ taskId: run.taskId,
1124
+ })
1125
+ .onConflictDoUpdate({
1126
+ target: juniorSchedulerRuns.id,
1127
+ set: {
1128
+ record: sql`excluded.record`,
1129
+ scheduledForMs: sql`excluded.scheduled_for_ms`,
1130
+ status: sql`excluded.status`,
1131
+ taskId: sql`excluded.task_id`,
1132
+ },
1133
+ });
1134
+ }
1135
+
1136
+ async function getTaskFromSql(
1137
+ db: SchedulerDb,
1138
+ taskId: string,
1139
+ ): Promise<ScheduledTask | undefined> {
1140
+ const rows = await db
1141
+ .select({ record: juniorSchedulerTasks.record })
1142
+ .from(juniorSchedulerTasks)
1143
+ .where(eq(juniorSchedulerTasks.id, taskId))
1144
+ .limit(1);
1145
+ return rows[0] ? parseSqlTaskRow(rows[0]) : undefined;
1146
+ }
1147
+
1148
+ async function getRunFromSql(
1149
+ db: SchedulerDb,
1150
+ runId: string,
1151
+ ): Promise<ScheduledRun | undefined> {
1152
+ const rows = await db
1153
+ .select({ record: juniorSchedulerRuns.record })
1154
+ .from(juniorSchedulerRuns)
1155
+ .where(eq(juniorSchedulerRuns.id, runId))
1156
+ .limit(1);
1157
+ return rows[0] ? parseSqlRunRow(rows[0]) : undefined;
1158
+ }
1159
+
1160
+ async function listTasksFromSql(db: SchedulerDb): Promise<ScheduledTask[]> {
1161
+ const rows = await db
1162
+ .select({ record: juniorSchedulerTasks.record })
1163
+ .from(juniorSchedulerTasks)
1164
+ .where(ne(juniorSchedulerTasks.status, "deleted"))
1165
+ .orderBy(
1166
+ asc(juniorSchedulerTasks.createdAtMs),
1167
+ asc(juniorSchedulerTasks.id),
1168
+ );
1169
+ return rows.map(parseSqlTaskRow).filter(present);
1170
+ }
1171
+
1172
+ async function listTasksForTeamFromSql(
1173
+ db: SchedulerDb,
1174
+ teamId: string,
1175
+ ): Promise<ScheduledTask[]> {
1176
+ const rows = await db
1177
+ .select({ record: juniorSchedulerTasks.record })
1178
+ .from(juniorSchedulerTasks)
1179
+ .where(
1180
+ and(
1181
+ eq(juniorSchedulerTasks.teamId, teamId),
1182
+ ne(juniorSchedulerTasks.status, "deleted"),
1183
+ ),
1184
+ )
1185
+ .orderBy(
1186
+ asc(juniorSchedulerTasks.createdAtMs),
1187
+ asc(juniorSchedulerTasks.id),
1188
+ );
1189
+ return rows.map(parseSqlTaskRow).filter(present);
1190
+ }
1191
+
1192
+ async function listIncompleteRunsForTasksFromSql(
1193
+ db: SchedulerDb,
1194
+ tasks: ScheduledTask[],
1195
+ ): Promise<ScheduledRun[]> {
1196
+ if (tasks.length === 0) {
1197
+ return [];
1198
+ }
1199
+ const rows = await db
1200
+ .select({ record: juniorSchedulerRuns.record })
1201
+ .from(juniorSchedulerRuns)
1202
+ .where(
1203
+ and(
1204
+ inArray(
1205
+ juniorSchedulerRuns.taskId,
1206
+ tasks.map((task) => task.id),
1207
+ ),
1208
+ inArray(juniorSchedulerRuns.status, [...SQL_INCOMPLETE_RUN_STATUSES]),
1209
+ ),
1210
+ )
1211
+ .orderBy(
1212
+ asc(juniorSchedulerRuns.scheduledForMs),
1213
+ asc(juniorSchedulerRuns.id),
1214
+ );
1215
+ return rows.map(parseSqlRunRow).filter(present);
1216
+ }
1217
+
1218
+ class SqlSchedulerStore implements SchedulerStore, SchedulerOperationalStore {
1219
+ constructor(private readonly db: SchedulerDb) {}
1220
+
1221
+ async saveTask(task: ScheduledTask): Promise<void> {
1222
+ const next = requireStoredTask(task);
1223
+ await withSqlLock(this.db, taskLockKey(task.id), async (db) => {
1224
+ const current = await getTaskFromSql(db, task.id);
1225
+ await this.saveTaskRecord(db, next, current);
1226
+ });
1227
+ }
1228
+
1229
+ private async saveTaskRecord(
1230
+ db: SchedulerDb,
1231
+ task: ScheduledTask,
1232
+ current: ScheduledTask | undefined,
1233
+ ): Promise<void> {
1234
+ // Reactivation intentionally forgets the blocked slot so authorization or
1235
+ // configuration fixes can dispatch the same scheduled occurrence again.
1236
+ if (
1237
+ current?.status === "blocked" &&
1238
+ task.status === "active" &&
1239
+ typeof task.nextRunAtMs === "number" &&
1240
+ Number.isFinite(task.nextRunAtMs)
1241
+ ) {
1242
+ await db
1243
+ .delete(juniorSchedulerRuns)
1244
+ .where(
1245
+ and(
1246
+ eq(juniorSchedulerRuns.id, buildRunId(task.id, task.nextRunAtMs)),
1247
+ eq(juniorSchedulerRuns.status, "blocked"),
1248
+ ),
1249
+ );
1250
+ }
1251
+ await upsertSqlTask(db, task);
1252
+ }
1253
+
1254
+ async getTask(taskId: string): Promise<ScheduledTask | undefined> {
1255
+ return await getTaskFromSql(this.db, taskId);
1256
+ }
1257
+
1258
+ async listTasks(): Promise<ScheduledTask[]> {
1259
+ return await listTasksFromSql(this.db);
1260
+ }
1261
+
1262
+ async listTasksForTeam(teamId: string): Promise<ScheduledTask[]> {
1263
+ return await listTasksForTeamFromSql(this.db, teamId);
1264
+ }
1265
+
1266
+ async claimDueRun(args: {
1267
+ nowMs: number;
1268
+ }): Promise<ScheduledRun | undefined> {
1269
+ return await withSqlLock(this.db, "junior:scheduler:claim", async (db) => {
1270
+ const rows = await db
1271
+ .select({ record: juniorSchedulerTasks.record })
1272
+ .from(juniorSchedulerTasks)
1273
+ .where(
1274
+ and(
1275
+ eq(juniorSchedulerTasks.status, "active"),
1276
+ or(
1277
+ and(
1278
+ isNotNull(juniorSchedulerTasks.runNowAtMs),
1279
+ lte(juniorSchedulerTasks.runNowAtMs, args.nowMs),
1280
+ ),
1281
+ and(
1282
+ isNotNull(juniorSchedulerTasks.nextRunAtMs),
1283
+ lte(juniorSchedulerTasks.nextRunAtMs, args.nowMs),
1284
+ ),
1285
+ ),
1286
+ ),
1287
+ )
1288
+ .orderBy(
1289
+ asc(juniorSchedulerTasks.createdAtMs),
1290
+ asc(juniorSchedulerTasks.id),
1291
+ );
1292
+
1293
+ for (const row of rows) {
1294
+ const task = parseSqlTaskRow(row);
1295
+ if (!task) {
1296
+ continue;
1297
+ }
1298
+ const scheduledForMs = getDueRunAtMs(task, args.nowMs);
1299
+ if (scheduledForMs === undefined) {
1300
+ continue;
1301
+ }
1302
+ const runId = buildRunId(task.id, scheduledForMs);
1303
+ const incompleteRuns = await listIncompleteRunsForTasksFromSql(db, [
1304
+ task,
1305
+ ]);
1306
+ const incompleteRun = incompleteRuns.find((run) => run.id === runId);
1307
+ const blockingRun = incompleteRuns.find(
1308
+ (run) => run.id !== runId && !isStalePendingRun(run, args.nowMs),
1309
+ );
1310
+ if (blockingRun) {
1311
+ continue;
1312
+ }
1313
+ if (incompleteRun) {
1314
+ if (!isStalePendingRun(incompleteRun, args.nowMs)) {
1315
+ continue;
1316
+ }
1317
+ const reclaimed = {
1318
+ ...incompleteRun,
1319
+ attempt: incompleteRun.attempt + 1,
1320
+ claimedAtMs: args.nowMs,
1321
+ };
1322
+ await upsertSqlRun(db, reclaimed);
1323
+ return reclaimed;
1324
+ }
1325
+ const existingRun = await getRunFromSql(db, runId);
1326
+ if (existingRun) {
1327
+ continue;
1328
+ }
1329
+
1330
+ if (isMissedRunTooOld({ nowMs: args.nowMs, scheduledForMs })) {
1331
+ await this.skipMissedRun(db, {
1332
+ nowMs: args.nowMs,
1333
+ scheduledForMs,
1334
+ task,
1335
+ });
1336
+ continue;
1337
+ }
1338
+
1339
+ const run = buildScheduledRun({
1340
+ claimedAtMs: args.nowMs,
1341
+ scheduledForMs,
1342
+ task,
1343
+ });
1344
+ await upsertSqlRun(db, run);
1345
+ return run;
1346
+ }
1347
+
1348
+ return undefined;
1349
+ });
1350
+ }
1351
+
1352
+ private async skipMissedRun(
1353
+ db: SchedulerDb,
1354
+ args: {
1355
+ nowMs: number;
1356
+ scheduledForMs: number;
1357
+ task: ScheduledTask;
1358
+ },
1359
+ ): Promise<void> {
1360
+ const current = await getTaskFromSql(db, args.task.id);
1361
+ if (
1362
+ !current ||
1363
+ current.status !== "active" ||
1364
+ getDueRunAtMs(current, args.nowMs) !== args.scheduledForMs
1365
+ ) {
1366
+ return;
1367
+ }
1368
+
1369
+ const duplicateOf = await this.findStaleRecoveryCanonicalTask(db, current);
1370
+ const errorMessage = duplicateOf
1371
+ ? `Duplicate stale scheduled task was skipped without dispatch. Canonical task: ${duplicateOf.id}.`
1372
+ : "Scheduled occurrence was more than 24 hours late and was skipped without dispatch.";
1373
+ await upsertSqlRun(
1374
+ db,
1375
+ buildSkippedScheduledRun({
1376
+ completedAtMs: args.nowMs,
1377
+ errorMessage,
1378
+ scheduledForMs: args.scheduledForMs,
1379
+ task: current,
1380
+ }),
1381
+ );
1382
+
1383
+ const isRunNow = current.runNowAtMs === args.scheduledForMs;
1384
+ let nextRunAtMs: number | undefined;
1385
+ if (!duplicateOf) {
1386
+ nextRunAtMs =
1387
+ isRunNow && current.nextRunAtMs !== args.scheduledForMs
1388
+ ? current.nextRunAtMs
1389
+ : current.schedule.kind === "recurring"
1390
+ ? getNextRunAtMs(current, args.scheduledForMs, args.nowMs)
1391
+ : undefined;
1392
+ }
1393
+ const nextStatus = nextRunAtMs ? "active" : "paused";
1394
+
1395
+ await this.saveTaskRecord(
1396
+ db,
1397
+ {
1398
+ ...current,
1399
+ nextRunAtMs,
1400
+ runNowAtMs: isRunNow ? undefined : current.runNowAtMs,
1401
+ status: nextStatus,
1402
+ statusReason: nextStatus === "paused" ? errorMessage : undefined,
1403
+ updatedAtMs: args.nowMs,
1404
+ },
1405
+ current,
1406
+ );
1407
+ }
1408
+
1409
+ private async findStaleRecoveryCanonicalTask(
1410
+ db: SchedulerDb,
1411
+ task: ScheduledTask,
1412
+ ): Promise<ScheduledTask | undefined> {
1413
+ const fingerprint = taskDedupeFingerprint(task);
1414
+ const tasks = await listTasksForTeamFromSql(db, task.destination.teamId);
1415
+ return tasks
1416
+ .filter((candidate) => candidate.id !== task.id)
1417
+ .filter(
1418
+ (candidate) =>
1419
+ candidate.status === "active" &&
1420
+ isEarlierTask(candidate, task) &&
1421
+ taskDedupeFingerprint(candidate) === fingerprint,
1422
+ )
1423
+ .sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id))
1424
+ .at(0);
1425
+ }
1426
+
1427
+ async getRun(runId: string): Promise<ScheduledRun | undefined> {
1428
+ return await getRunFromSql(this.db, runId);
1429
+ }
1430
+
1431
+ async listIncompleteRuns(): Promise<ScheduledRun[]> {
1432
+ return await listIncompleteRunsForTasksFromSql(
1433
+ this.db,
1434
+ await this.listTasks(),
1435
+ );
1436
+ }
1437
+
1438
+ async listIncompleteRunsForTasks(
1439
+ tasks: ScheduledTask[],
1440
+ ): Promise<ScheduledRun[]> {
1441
+ return await listIncompleteRunsForTasksFromSql(this.db, tasks);
1442
+ }
1443
+
1444
+ async markRunDispatched(args: {
1445
+ claimedAtMs: number;
1446
+ dispatchId: string;
1447
+ nowMs: number;
1448
+ runId: string;
1449
+ }): Promise<ScheduledRun | undefined> {
1450
+ return await this.updateRun(args.runId, (run) =>
1451
+ run.status === "pending" && run.claimedAtMs === args.claimedAtMs
1452
+ ? {
1453
+ ...run,
1454
+ dispatchId: args.dispatchId,
1455
+ startedAtMs: args.nowMs,
1456
+ status: "running",
1457
+ }
1458
+ : undefined,
1459
+ );
1460
+ }
1461
+
1462
+ async markRunCompleted(args: {
1463
+ completedAtMs: number;
1464
+ resultMessageTs?: string;
1465
+ runId: string;
1466
+ startedAtMs: number;
1467
+ }): Promise<ScheduledRun | undefined> {
1468
+ const next = await this.updateRun(args.runId, (run) =>
1469
+ canFinishRun(run, args.startedAtMs)
1470
+ ? {
1471
+ ...run,
1472
+ completedAtMs: args.completedAtMs,
1473
+ resultMessageTs: args.resultMessageTs,
1474
+ status: "completed",
1475
+ }
1476
+ : undefined,
1477
+ );
1478
+ return next;
1479
+ }
1480
+
1481
+ async markRunFailed(args: {
1482
+ completedAtMs: number;
1483
+ errorMessage: string;
1484
+ startedAtMs?: number;
1485
+ runId: string;
1486
+ }): Promise<ScheduledRun | undefined> {
1487
+ return await this.updateRun(args.runId, (run) =>
1488
+ canFinishRun(run, args.startedAtMs)
1489
+ ? {
1490
+ ...run,
1491
+ completedAtMs: args.completedAtMs,
1492
+ errorMessage: args.errorMessage,
1493
+ status: "failed",
1494
+ }
1495
+ : undefined,
1496
+ );
1497
+ }
1498
+
1499
+ async markRunSkipped(args: {
1500
+ completedAtMs: number;
1501
+ errorMessage: string;
1502
+ runId: string;
1503
+ }): Promise<ScheduledRun | undefined> {
1504
+ return await this.updateRun(args.runId, (run) =>
1505
+ run.status === "pending"
1506
+ ? {
1507
+ ...run,
1508
+ completedAtMs: args.completedAtMs,
1509
+ errorMessage: args.errorMessage,
1510
+ status: "skipped",
1511
+ }
1512
+ : undefined,
1513
+ );
1514
+ }
1515
+
1516
+ async markRunBlocked(args: {
1517
+ completedAtMs: number;
1518
+ errorMessage: string;
1519
+ runId: string;
1520
+ startedAtMs?: number;
1521
+ }): Promise<ScheduledRun | undefined> {
1522
+ return await this.updateRun(args.runId, (run) =>
1523
+ canFinishRun(run, args.startedAtMs)
1524
+ ? {
1525
+ ...run,
1526
+ completedAtMs: args.completedAtMs,
1527
+ errorMessage: args.errorMessage,
1528
+ status: "blocked",
1529
+ }
1530
+ : undefined,
1531
+ );
1532
+ }
1533
+
1534
+ async updateTaskAfterRun(args: {
1535
+ errorMessage?: string;
1536
+ nowMs: number;
1537
+ run: ScheduledRun;
1538
+ status: "blocked" | "completed" | "failed";
1539
+ }): Promise<void> {
1540
+ await withSqlLock(this.db, taskLockKey(args.run.taskId), async (db) => {
1541
+ const current = await getTaskFromSql(db, args.run.taskId);
1542
+ if (!current || current.status === "deleted") {
1543
+ return;
1544
+ }
1545
+
1546
+ const isRunNow = current.runNowAtMs === args.run.scheduledForMs;
1547
+ if (isRunNow) {
1548
+ let nextRunAtMs = current.nextRunAtMs;
1549
+ if (
1550
+ args.status !== "blocked" &&
1551
+ typeof current.nextRunAtMs === "number" &&
1552
+ current.nextRunAtMs <= args.run.scheduledForMs
1553
+ ) {
1554
+ nextRunAtMs = getNextRunAtMs(
1555
+ current,
1556
+ current.nextRunAtMs,
1557
+ args.nowMs,
1558
+ );
1559
+ }
1560
+ await this.saveTaskRecord(
1561
+ db,
1562
+ {
1563
+ ...current,
1564
+ lastRunAtMs: args.run.scheduledForMs,
1565
+ nextRunAtMs,
1566
+ runNowAtMs: undefined,
1567
+ status:
1568
+ args.status === "blocked"
1569
+ ? "blocked"
1570
+ : nextRunAtMs
1571
+ ? current.status
1572
+ : "paused",
1573
+ statusReason:
1574
+ args.status === "blocked" ? args.errorMessage : undefined,
1575
+ updatedAtMs: args.nowMs,
1576
+ },
1577
+ current,
1578
+ );
1579
+ return;
1580
+ }
1581
+
1582
+ if (
1583
+ current.status !== "active" ||
1584
+ current.nextRunAtMs !== args.run.scheduledForMs
1585
+ ) {
1586
+ await this.saveTaskRecord(
1587
+ db,
1588
+ {
1589
+ ...current,
1590
+ lastRunAtMs: args.run.scheduledForMs,
1591
+ updatedAtMs: args.nowMs,
1592
+ },
1593
+ current,
1594
+ );
1595
+ return;
1596
+ }
1597
+
1598
+ const nextRunAtMs =
1599
+ args.status === "blocked"
1600
+ ? undefined
1601
+ : getNextRunAtMs(current, args.run.scheduledForMs, args.nowMs);
1602
+
1603
+ await this.saveTaskRecord(
1604
+ db,
1605
+ {
1606
+ ...current,
1607
+ lastRunAtMs: args.run.scheduledForMs,
1608
+ nextRunAtMs,
1609
+ status:
1610
+ args.status === "blocked"
1611
+ ? "blocked"
1612
+ : nextRunAtMs
1613
+ ? "active"
1614
+ : "paused",
1615
+ statusReason:
1616
+ args.status === "blocked" ? args.errorMessage : undefined,
1617
+ updatedAtMs: args.nowMs,
1618
+ },
1619
+ current,
1620
+ );
1621
+ });
1622
+ }
1623
+
1624
+ private async updateRun(
1625
+ runId: string,
1626
+ update: (run: ScheduledRun) => ScheduledRun | undefined,
1627
+ ): Promise<ScheduledRun | undefined> {
1628
+ return await withSqlLock(
1629
+ this.db,
1630
+ indexLockKey(runKey(runId)),
1631
+ async (db) => {
1632
+ const current = await getRunFromSql(db, runId);
1633
+ if (!current) {
1634
+ return undefined;
1635
+ }
1636
+ const next = update(current);
1637
+ if (!next) {
1638
+ return undefined;
1639
+ }
1640
+ await upsertSqlRun(db, next);
1641
+ return next;
1642
+ },
1643
+ );
1644
+ }
1645
+ }
1646
+
1647
+ /** Create a scheduler store backed by the plugin SQL database. */
1648
+ export function createSchedulerSqlStore(db: SchedulerDb): SchedulerStore {
1649
+ return new SqlSchedulerStore(db);
1650
+ }
1651
+
1652
+ /** Create a read-only scheduler operational store backed by SQL. */
1653
+ export function createSchedulerOperationalSqlStore(
1654
+ db: SchedulerDb,
1655
+ ): SchedulerOperationalStore {
1656
+ return new SqlSchedulerStore(db);
1657
+ }
1658
+
1659
+ /** Copy retained scheduler plugin-state records into the scheduler SQL tables. */
1660
+ export async function migrateSchedulerStateToSql(args: {
1661
+ db: SchedulerDb;
1662
+ state: PluginState;
1663
+ }): Promise<{
1664
+ existing: number;
1665
+ migrated: number;
1666
+ missing: number;
1667
+ scanned: number;
1668
+ }> {
1669
+ const store = createSchedulerSqlStore(args.db);
1670
+ const ids = await getIndex(args.state, globalTaskIndexKey());
1671
+ let existing = 0;
1672
+ let migrated = 0;
1673
+ let missing = 0;
1674
+ const migratedTasks: ScheduledTask[] = [];
1675
+
1676
+ for (const id of ids) {
1677
+ const task = await getTaskFromState(args.state, id);
1678
+ if (!task) {
1679
+ missing += 1;
1680
+ continue;
1681
+ }
1682
+ migratedTasks.push(task);
1683
+ if (await store.getTask(task.id)) {
1684
+ existing += 1;
1685
+ continue;
1686
+ }
1687
+ await store.saveTask(task);
1688
+ migrated += 1;
1689
+ }
1690
+
1691
+ const runs = await listIncompleteRunsForTasksFromState(
1692
+ args.state,
1693
+ migratedTasks,
1694
+ );
1695
+ for (const run of runs) {
1696
+ if (await store.getRun(run.id)) {
1697
+ existing += 1;
1698
+ continue;
1699
+ }
1700
+ await upsertSqlRun(args.db, run);
1701
+ migrated += 1;
1702
+ }
1703
+
1704
+ return {
1705
+ existing,
1706
+ migrated,
1707
+ missing,
1708
+ scanned: ids.length + runs.length,
1709
+ };
1710
+ }