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