@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/dist/index.js CHANGED
@@ -1,133 +1,27 @@
1
1
  // src/plugin.ts
2
2
  import {
3
+ createSlackSource,
3
4
  defineJuniorPlugin
4
5
  } from "@sentry/junior-plugin-api";
5
6
 
6
- // src/types.ts
7
- var SCHEDULED_TASK_SYSTEM_ACTOR = Object.freeze({
8
- type: "system",
9
- id: "scheduled-task"
10
- });
11
-
12
- // src/identity.ts
13
- var SLACK_USER_ID_PATTERN = /^[UW][A-Z0-9]{5,}$/;
14
- function clean(value) {
15
- const normalized = value?.trim();
16
- return normalized ? normalized : void 0;
17
- }
18
- function cleanSlackUserId(value) {
19
- const normalized = clean(value);
20
- return normalized && normalized.toLowerCase() !== "unknown" ? normalized : void 0;
21
- }
22
- function cleanDisplay(value, slackUserId) {
23
- const normalized = clean(value);
24
- if (!normalized || normalized.toLowerCase() === "unknown" || normalized === slackUserId) {
25
- return void 0;
26
- }
27
- return SLACK_USER_ID_PATTERN.test(normalized) ? void 0 : normalized;
28
- }
29
- function sanitizeScheduledTaskPrincipal(principal) {
30
- const slackUserId = cleanSlackUserId(principal.slackUserId);
31
- if (!slackUserId) {
32
- throw new Error("Scheduled task principal requires a Slack user id");
33
- }
34
- const fullName = cleanDisplay(principal.fullName, slackUserId);
35
- const userName = cleanDisplay(principal.userName, slackUserId);
36
- return {
37
- slackUserId,
38
- ...fullName ? { fullName } : {},
39
- ...userName ? { userName } : {}
40
- };
41
- }
42
- function scheduledTaskPrincipalLabel(principal) {
43
- const author = sanitizeScheduledTaskPrincipal(principal);
44
- if (author.fullName && author.userName) {
45
- return `${author.fullName} (@${author.userName})`;
46
- }
47
- if (author.fullName) {
48
- return author.fullName;
49
- }
50
- if (author.userName) {
51
- return `@${author.userName}`;
52
- }
53
- return `Slack User ${author.slackUserId}`;
54
- }
55
-
56
- // src/prompt.ts
57
- function escapeXml(value) {
58
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
59
- }
60
- var EXECUTION_RULES = [
61
- "- Execute as the scheduled-task system actor; creator metadata is audit context, not an active user identity.",
62
- "- Complete the task without asking follow-up questions unless access, approval, or required input is missing.",
63
- "- Use the available tools and skills that are relevant to the task contract.",
64
- "- Do not create, edit, or discuss scheduling during this run; the stored schedule already fired.",
65
- "- For reminder tasks, deliver the reminder message now instead of explaining how reminders or delayed posts work.",
66
- "- If blocked, report the specific missing provider, permission, configuration, or input.",
67
- "- Keep the final result shaped for the configured destination audience."
68
- ];
69
- function renderOptionalLine(name, value) {
70
- return value?.trim() ? [`- ${name}: ${escapeXml(value.trim())}`] : [];
71
- }
72
- function buildScheduledTaskRunPrompt(args) {
73
- const { run, task } = args;
74
- const destination = task.destination;
75
- const creator = sanitizeScheduledTaskPrincipal(task.createdBy);
76
- const executionActor = task.executionActor ?? SCHEDULED_TASK_SYSTEM_ACTOR;
77
- if (!task.task.text?.trim()) {
78
- throw new Error("Scheduled task text is required");
79
- }
80
- return [
81
- "<scheduled-task-run>",
82
- "This is an autonomous scheduled run. Treat the stored task contract as the user request for this turn.",
83
- "",
84
- "<scheduled-task>",
85
- `- id: ${escapeXml(task.id)}`,
86
- "<task-text>",
87
- escapeXml(task.task.text),
88
- "</task-text>",
89
- "</scheduled-task>",
90
- "",
91
- "<run-context>",
92
- `- run_id: ${escapeXml(run.id)}`,
93
- `- task_version: ${run.taskVersion}`,
94
- `- scheduled_for: ${new Date(run.scheduledForMs).toISOString()}`,
95
- `- running_at: ${new Date(args.nowMs).toISOString()}`,
96
- `- schedule: ${escapeXml(task.schedule.description)}`,
97
- `- timezone: ${escapeXml(task.schedule.timezone)}`,
98
- `- schedule_kind: ${task.schedule.kind}`,
99
- `- execution_actor_type: ${executionActor.type}`,
100
- `- execution_actor_id: ${escapeXml(executionActor.id)}`,
101
- ...task.schedule.recurrence ? [
102
- `- recurrence_frequency: ${task.schedule.recurrence.frequency}`,
103
- `- recurrence_interval: ${task.schedule.recurrence.interval}`,
104
- `- recurrence_start_date: ${escapeXml(task.schedule.recurrence.startDate)}`
105
- ] : [],
106
- `- creator_slack_user_id: ${escapeXml(creator.slackUserId)}`,
107
- ...renderOptionalLine("creator_user_name", creator.userName),
108
- ...renderOptionalLine("creator_full_name", creator.fullName),
109
- `- destination_platform: ${destination.platform}`,
110
- `- destination_team_id: ${escapeXml(destination.teamId)}`,
111
- `- destination_channel_id: ${escapeXml(destination.channelId)}`,
112
- "</run-context>",
113
- "",
114
- "<execution-rules>",
115
- ...EXECUTION_RULES,
116
- "</execution-rules>",
117
- "",
118
- '<current-instruction priority="highest">',
119
- "Execute the scheduled task now and provide the final result for the configured destination.",
120
- "</current-instruction>",
121
- "</scheduled-task-run>"
122
- ].join("\n");
123
- }
124
-
125
7
  // src/store.ts
126
8
  import {
127
- agentPluginCredentialSubjectSchema,
9
+ pluginCredentialSubjectSchema,
128
10
  destinationSchema,
129
11
  isSlackDestination
130
12
  } from "@sentry/junior-plugin-api";
13
+ import {
14
+ and,
15
+ asc,
16
+ eq,
17
+ inArray,
18
+ isNotNull,
19
+ lte,
20
+ ne,
21
+ or,
22
+ sql as sql2
23
+ } from "drizzle-orm";
24
+ import { z } from "zod";
131
25
 
132
26
  // src/cadence.ts
133
27
  function parseScheduleTimestamp(value) {
@@ -215,7 +109,7 @@ function localDateTimeToTimestampMs(args) {
215
109
  0
216
110
  );
217
111
  let timestampMs = localAsUtcMs - getTimeZoneOffsetMs(localAsUtcMs, args.timezone);
218
- for (let index = 0; index < 3; index += 1) {
112
+ for (let index2 = 0; index2 < 3; index2 += 1) {
219
113
  const next = localAsUtcMs - getTimeZoneOffsetMs(timestampMs, args.timezone);
220
114
  if (next === timestampMs) {
221
115
  break;
@@ -476,6 +370,52 @@ function getNextRunAtMs(task, scheduledForMs, afterMs = scheduledForMs) {
476
370
  });
477
371
  }
478
372
 
373
+ // src/db/schema.ts
374
+ import { sql } from "drizzle-orm";
375
+ import { bigint, index, jsonb, pgTable, text } from "drizzle-orm/pg-core";
376
+ var juniorSchedulerTasks = pgTable(
377
+ "junior_scheduler_tasks",
378
+ {
379
+ id: text("id").primaryKey(),
380
+ teamId: text("team_id").notNull(),
381
+ status: text("status").notNull(),
382
+ nextRunAtMs: bigint("next_run_at_ms", { mode: "number" }),
383
+ runNowAtMs: bigint("run_now_at_ms", { mode: "number" }),
384
+ createdAtMs: bigint("created_at_ms", { mode: "number" }).notNull(),
385
+ record: jsonb("record").$type().notNull()
386
+ },
387
+ (table) => [
388
+ index("junior_scheduler_tasks_team_status_idx").on(table.teamId, table.createdAtMs, table.id).where(sql`${table.status} <> 'deleted'`),
389
+ index("junior_scheduler_tasks_run_now_due_idx").on(table.runNowAtMs, table.createdAtMs, table.id).where(
390
+ sql`${table.status} = 'active' AND ${table.runNowAtMs} IS NOT NULL`
391
+ ),
392
+ index("junior_scheduler_tasks_next_run_due_idx").on(table.nextRunAtMs, table.createdAtMs, table.id).where(
393
+ sql`${table.status} = 'active' AND ${table.nextRunAtMs} IS NOT NULL`
394
+ )
395
+ ]
396
+ );
397
+ var juniorSchedulerRuns = pgTable(
398
+ "junior_scheduler_runs",
399
+ {
400
+ id: text("id").primaryKey(),
401
+ taskId: text("task_id").notNull(),
402
+ status: text("status").notNull(),
403
+ scheduledForMs: bigint("scheduled_for_ms", { mode: "number" }).notNull(),
404
+ record: jsonb("record").$type().notNull()
405
+ },
406
+ (table) => [
407
+ index("junior_scheduler_runs_task_status_idx").on(
408
+ table.taskId,
409
+ table.status,
410
+ table.scheduledForMs
411
+ ),
412
+ index("junior_scheduler_runs_status_idx").on(
413
+ table.status,
414
+ table.scheduledForMs
415
+ )
416
+ ]
417
+ );
418
+
479
419
  // src/store.ts
480
420
  var SCHEDULER_KEY_PREFIX = "junior:scheduler";
481
421
  var SCHEDULER_RECORD_TTL_MS = 5 * 365 * 24 * 60 * 60 * 1e3;
@@ -483,7 +423,81 @@ var SCHEDULED_RUN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
483
423
  var CLAIM_TTL_MS = 6 * 60 * 60 * 1e3;
484
424
  var PENDING_CLAIM_STALE_MS = 6e4;
485
425
  var MISSED_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
486
- var LOCK_TTL_MS = 1e4;
426
+ var SQL_INCOMPLETE_RUN_STATUSES = ["pending", "running"];
427
+ var slackDestinationSchema = destinationSchema.refine(isSlackDestination);
428
+ var taskPrincipalSchema = z.object({
429
+ slackUserId: z.string(),
430
+ fullName: z.string().optional(),
431
+ userName: z.string().optional()
432
+ }).strict();
433
+ var recurrenceSchema = z.object({
434
+ dayOfMonth: z.number().optional(),
435
+ frequency: z.enum(["daily", "weekly", "monthly", "yearly"]),
436
+ interval: z.number(),
437
+ month: z.number().optional(),
438
+ startDate: z.string(),
439
+ time: z.object({
440
+ hour: z.number(),
441
+ minute: z.number()
442
+ }).strict(),
443
+ weekdays: z.array(z.number()).optional()
444
+ }).strict();
445
+ var taskScheduleSchema = z.object({
446
+ description: z.string(),
447
+ kind: z.enum(["one_off", "recurring"]),
448
+ recurrence: recurrenceSchema.optional(),
449
+ timezone: z.string()
450
+ }).strict();
451
+ var taskSpecSchema = z.object({
452
+ text: z.string()
453
+ }).strict();
454
+ var taskRecordSchema = z.object({
455
+ id: z.string(),
456
+ conversationAccess: z.object({
457
+ audience: z.enum(["direct", "group", "channel"]),
458
+ visibility: z.enum(["private", "public", "unknown"])
459
+ }).strict().optional(),
460
+ createdAtMs: z.number(),
461
+ createdBy: taskPrincipalSchema,
462
+ credentialSubject: pluginCredentialSubjectSchema.optional(),
463
+ destination: slackDestinationSchema,
464
+ executionActor: z.object({
465
+ type: z.literal("system"),
466
+ id: z.string()
467
+ }).strict().optional(),
468
+ lastRunAtMs: z.number().optional(),
469
+ nextRunAtMs: z.number().optional(),
470
+ originalRequest: z.string().optional(),
471
+ runNowAtMs: z.number().optional(),
472
+ schedule: taskScheduleSchema,
473
+ status: z.enum(["active", "paused", "blocked", "deleted"]),
474
+ statusReason: z.string().optional(),
475
+ task: taskSpecSchema,
476
+ updatedAtMs: z.number(),
477
+ version: z.number().optional()
478
+ }).strict();
479
+ var runRecordSchema = z.object({
480
+ id: z.string(),
481
+ attempt: z.number(),
482
+ claimedAtMs: z.number(),
483
+ completedAtMs: z.number().optional(),
484
+ dispatchId: z.string().optional(),
485
+ errorMessage: z.string().optional(),
486
+ idempotencyKey: z.string().optional(),
487
+ resultMessageTs: z.string().optional(),
488
+ scheduledForMs: z.number(),
489
+ startedAtMs: z.number().optional(),
490
+ status: z.enum([
491
+ "pending",
492
+ "running",
493
+ "completed",
494
+ "failed",
495
+ "blocked",
496
+ "skipped"
497
+ ]),
498
+ taskId: z.string(),
499
+ taskVersion: z.number().optional()
500
+ }).strict();
487
501
  function taskKey(taskId) {
488
502
  return `${SCHEDULER_KEY_PREFIX}:task:${taskId}`;
489
503
  }
@@ -493,18 +507,12 @@ function taskLockKey(taskId) {
493
507
  function runKey(runId) {
494
508
  return `${SCHEDULER_KEY_PREFIX}:run:${runId}`;
495
509
  }
496
- function claimKey(taskId, scheduledForMs) {
497
- return `${SCHEDULER_KEY_PREFIX}:claim:${taskId}:${scheduledForMs}`;
498
- }
499
510
  function activeRunKey(taskId) {
500
511
  return `${SCHEDULER_KEY_PREFIX}:active:${taskId}`;
501
512
  }
502
513
  function globalTaskIndexKey() {
503
514
  return `${SCHEDULER_KEY_PREFIX}:tasks`;
504
515
  }
505
- function teamTaskIndexKey(teamId) {
506
- return `${SCHEDULER_KEY_PREFIX}:team:${teamId}:tasks`;
507
- }
508
516
  function indexLockKey(indexKey) {
509
517
  return `${indexKey}:lock`;
510
518
  }
@@ -514,80 +522,18 @@ function buildRunId(taskId, scheduledForMs) {
514
522
  function unique(values) {
515
523
  return [...new Set(values.filter(Boolean))];
516
524
  }
517
- async function withLock(state, key, callback) {
518
- return await state.withLock(key, LOCK_TTL_MS, callback);
519
- }
520
- async function addToIndex(state, key, taskId) {
521
- await withLock(state, indexLockKey(key), async () => {
522
- const current = (await state.get(key) ?? []).filter(
523
- (value) => typeof value === "string"
524
- );
525
- await state.set(key, unique([...current, taskId]), SCHEDULER_RECORD_TTL_MS);
526
- });
527
- }
528
- async function removeFromIndex(state, key, taskId) {
529
- await withLock(state, indexLockKey(key), async () => {
530
- const current = unique(
531
- (await state.get(key) ?? []).filter(
532
- (value) => typeof value === "string"
533
- )
534
- );
535
- const next = current.filter((value) => value !== taskId);
536
- if (next.length === current.length) {
537
- return;
538
- }
539
- if (next.length === 0) {
540
- await state.delete(key);
541
- return;
542
- }
543
- await state.set(key, next, SCHEDULER_RECORD_TTL_MS);
544
- });
545
- }
546
525
  async function getIndex(state, key) {
547
526
  const values = await state.get(key) ?? [];
548
527
  return unique(
549
528
  values.filter((value) => typeof value === "string")
550
529
  );
551
530
  }
552
- async function clearActiveRun(state, taskId, runId) {
553
- await withLock(state, indexLockKey(activeRunKey(taskId)), async () => {
554
- const current = await state.get(activeRunKey(taskId));
555
- if (current?.runId === runId) {
556
- await state.delete(activeRunKey(taskId));
557
- }
558
- });
559
- }
560
- async function clearStaleActiveRun(state, taskId, nowMs) {
561
- const active = await state.get(activeRunKey(taskId));
562
- if (typeof active?.runId !== "string") {
563
- await state.delete(activeRunKey(taskId));
564
- return true;
565
- }
566
- const activeRun = await state.get(runKey(active.runId)) ?? void 0;
567
- if (!isStaleActiveRun(active, activeRun, nowMs)) {
568
- return false;
569
- }
570
- await clearActiveRun(state, taskId, active.runId);
571
- if (typeof active.scheduledForMs === "number") {
572
- await state.delete(claimKey(taskId, active.scheduledForMs));
573
- }
574
- return true;
575
- }
576
531
  function isFinishedRun(run) {
577
532
  return run.status === "completed" || run.status === "failed" || run.status === "blocked" || run.status === "skipped";
578
533
  }
579
- function isStaleActiveRun(active, run, nowMs) {
580
- if (run) {
581
- return isFinishedRun(run) || isStalePendingRun(run, nowMs);
582
- }
583
- return typeof active.claimedAtMs === "number" && active.claimedAtMs + PENDING_CLAIM_STALE_MS <= nowMs;
584
- }
585
534
  function isStalePendingRun(run, nowMs) {
586
535
  return run?.status === "pending" && run.claimedAtMs + PENDING_CLAIM_STALE_MS <= nowMs;
587
536
  }
588
- function isDueTask(task, nowMs) {
589
- return task.status === "active" && (typeof task.runNowAtMs === "number" && Number.isFinite(task.runNowAtMs) && task.runNowAtMs <= nowMs || typeof task.nextRunAtMs === "number" && Number.isFinite(task.nextRunAtMs) && task.nextRunAtMs <= nowMs);
590
- }
591
537
  function getDueRunAtMs(task, nowMs) {
592
538
  if (typeof task.runNowAtMs === "number" && Number.isFinite(task.runNowAtMs) && task.runNowAtMs <= nowMs) {
593
539
  return task.runNowAtMs;
@@ -598,16 +544,13 @@ function getDueRunAtMs(task, nowMs) {
598
544
  return void 0;
599
545
  }
600
546
  function buildScheduledRun(args) {
601
- const idempotencyKey = `${args.task.id}:${args.scheduledForMs}`;
602
547
  return {
603
548
  id: buildRunId(args.task.id, args.scheduledForMs),
604
549
  attempt: 1,
605
550
  claimedAtMs: args.claimedAtMs,
606
- idempotencyKey,
607
551
  scheduledForMs: args.scheduledForMs,
608
552
  status: "pending",
609
- taskId: args.task.id,
610
- taskVersion: args.task.version
553
+ taskId: args.task.id
611
554
  };
612
555
  }
613
556
  function buildSkippedScheduledRun(args) {
@@ -658,23 +601,40 @@ function canFinishRun(run, startedAtMs) {
658
601
  return run.status === "running" && run.startedAtMs === startedAtMs;
659
602
  }
660
603
  function parseStoredTask(value) {
661
- if (!value || typeof value !== "object") {
662
- return void 0;
663
- }
664
- const record = value;
665
- const destination = destinationSchema.safeParse(record.destination);
666
- if (!destination.success || !isSlackDestination(destination.data)) {
667
- return void 0;
604
+ const parsed = taskRecordSchema.safeParse(parseJsonRecord(value));
605
+ return parsed.success ? stripLegacyTaskFields(parsed.data) : void 0;
606
+ }
607
+ function parseStoredRun(value) {
608
+ const parsed = runRecordSchema.safeParse(parseJsonRecord(value));
609
+ return parsed.success ? stripLegacyRunFields(parsed.data) : void 0;
610
+ }
611
+ function stripLegacyTaskFields(task) {
612
+ const { version: _version, ...current } = task;
613
+ return current;
614
+ }
615
+ function stripLegacyRunFields(run) {
616
+ const {
617
+ idempotencyKey: _idempotencyKey,
618
+ taskVersion: _taskVersion,
619
+ ...current
620
+ } = run;
621
+ return current;
622
+ }
623
+ function parseJsonRecord(value) {
624
+ if (typeof value === "string") {
625
+ try {
626
+ return JSON.parse(value);
627
+ } catch {
628
+ return void 0;
629
+ }
668
630
  }
669
- const credentialSubject = record.credentialSubject === void 0 ? void 0 : agentPluginCredentialSubjectSchema.safeParse(record.credentialSubject);
670
- if (credentialSubject && !credentialSubject.success) {
671
- return void 0;
631
+ if (value && typeof value === "object") {
632
+ return value;
672
633
  }
673
- return {
674
- ...record,
675
- destination: destination.data,
676
- ...credentialSubject ? { credentialSubject: credentialSubject.data } : {}
677
- };
634
+ return void 0;
635
+ }
636
+ function present(value) {
637
+ return value !== void 0;
678
638
  }
679
639
  function requireStoredTask(task) {
680
640
  const parsed = parseStoredTask(task);
@@ -686,13 +646,8 @@ function requireStoredTask(task) {
686
646
  async function getTaskFromState(state, taskId) {
687
647
  return parseStoredTask(await state.get(taskKey(taskId)));
688
648
  }
689
- async function listTasksFromState(state, indexKey) {
690
- const ids = await getIndex(state, indexKey);
691
- const tasks = await Promise.all(ids.map((id) => getTaskFromState(state, id)));
692
- return tasks.filter((task) => Boolean(task)).filter((task) => task.status !== "deleted").sort((a, b) => a.createdAtMs - b.createdAtMs);
693
- }
694
649
  async function getRunFromState(state, runId) {
695
- return await state.get(runKey(runId)) ?? void 0;
650
+ return parseStoredRun(await state.get(runKey(runId)));
696
651
  }
697
652
  async function listIncompleteRunsForTasksFromState(state, tasks) {
698
653
  const runs = [];
@@ -708,191 +663,266 @@ async function listIncompleteRunsForTasksFromState(state, tasks) {
708
663
  }
709
664
  return runs;
710
665
  }
711
- var PluginStateSchedulerOperationalStore = class {
712
- state;
713
- constructor(state) {
714
- this.state = state;
715
- }
716
- async listTasks() {
717
- return await listTasksFromState(this.state, globalTaskIndexKey());
718
- }
719
- async listIncompleteRunsForTasks(tasks) {
720
- return await listIncompleteRunsForTasksFromState(this.state, tasks);
666
+ function parseSqlTaskRecord(value) {
667
+ const parsed = taskRecordSchema.safeParse(parseJsonRecord(value));
668
+ return parsed.success ? stripLegacyTaskFields(parsed.data) : void 0;
669
+ }
670
+ function parseSqlTaskRow(row) {
671
+ return parseSqlTaskRecord(row.record);
672
+ }
673
+ function parseSqlRunRow(row) {
674
+ const parsed = runRecordSchema.safeParse(parseJsonRecord(row.record));
675
+ return parsed.success ? stripLegacyRunFields(parsed.data) : void 0;
676
+ }
677
+ async function withSqlLock(db, key, callback) {
678
+ return await db.transaction(async (tx) => {
679
+ await tx.execute(sql2`SELECT pg_advisory_xact_lock(hashtext(${key}))`);
680
+ return await callback(tx);
681
+ });
682
+ }
683
+ async function upsertSqlTask(db, task) {
684
+ await db.insert(juniorSchedulerTasks).values({
685
+ createdAtMs: task.createdAtMs,
686
+ id: task.id,
687
+ nextRunAtMs: task.nextRunAtMs,
688
+ record: task,
689
+ runNowAtMs: task.runNowAtMs,
690
+ status: task.status,
691
+ teamId: task.destination.teamId
692
+ }).onConflictDoUpdate({
693
+ target: juniorSchedulerTasks.id,
694
+ set: {
695
+ createdAtMs: sql2`excluded.created_at_ms`,
696
+ nextRunAtMs: sql2`excluded.next_run_at_ms`,
697
+ record: sql2`excluded.record`,
698
+ runNowAtMs: sql2`excluded.run_now_at_ms`,
699
+ status: sql2`excluded.status`,
700
+ teamId: sql2`excluded.team_id`
701
+ }
702
+ });
703
+ }
704
+ async function upsertSqlRun(db, run) {
705
+ await db.insert(juniorSchedulerRuns).values({
706
+ id: run.id,
707
+ record: run,
708
+ scheduledForMs: run.scheduledForMs,
709
+ status: run.status,
710
+ taskId: run.taskId
711
+ }).onConflictDoUpdate({
712
+ target: juniorSchedulerRuns.id,
713
+ set: {
714
+ record: sql2`excluded.record`,
715
+ scheduledForMs: sql2`excluded.scheduled_for_ms`,
716
+ status: sql2`excluded.status`,
717
+ taskId: sql2`excluded.task_id`
718
+ }
719
+ });
720
+ }
721
+ async function getTaskFromSql(db, taskId) {
722
+ const rows = await db.select({ record: juniorSchedulerTasks.record }).from(juniorSchedulerTasks).where(eq(juniorSchedulerTasks.id, taskId)).limit(1);
723
+ return rows[0] ? parseSqlTaskRow(rows[0]) : void 0;
724
+ }
725
+ async function getRunFromSql(db, runId) {
726
+ const rows = await db.select({ record: juniorSchedulerRuns.record }).from(juniorSchedulerRuns).where(eq(juniorSchedulerRuns.id, runId)).limit(1);
727
+ return rows[0] ? parseSqlRunRow(rows[0]) : void 0;
728
+ }
729
+ async function listTasksFromSql(db) {
730
+ const rows = await db.select({ record: juniorSchedulerTasks.record }).from(juniorSchedulerTasks).where(ne(juniorSchedulerTasks.status, "deleted")).orderBy(
731
+ asc(juniorSchedulerTasks.createdAtMs),
732
+ asc(juniorSchedulerTasks.id)
733
+ );
734
+ return rows.map(parseSqlTaskRow).filter(present);
735
+ }
736
+ async function listTasksForTeamFromSql(db, teamId) {
737
+ const rows = await db.select({ record: juniorSchedulerTasks.record }).from(juniorSchedulerTasks).where(
738
+ and(
739
+ eq(juniorSchedulerTasks.teamId, teamId),
740
+ ne(juniorSchedulerTasks.status, "deleted")
741
+ )
742
+ ).orderBy(
743
+ asc(juniorSchedulerTasks.createdAtMs),
744
+ asc(juniorSchedulerTasks.id)
745
+ );
746
+ return rows.map(parseSqlTaskRow).filter(present);
747
+ }
748
+ async function listIncompleteRunsForTasksFromSql(db, tasks) {
749
+ if (tasks.length === 0) {
750
+ return [];
721
751
  }
722
- };
723
- var PluginStateSchedulerStore = class {
724
- state;
725
- constructor(state) {
726
- this.state = state;
752
+ const rows = await db.select({ record: juniorSchedulerRuns.record }).from(juniorSchedulerRuns).where(
753
+ and(
754
+ inArray(
755
+ juniorSchedulerRuns.taskId,
756
+ tasks.map((task) => task.id)
757
+ ),
758
+ inArray(juniorSchedulerRuns.status, [...SQL_INCOMPLETE_RUN_STATUSES])
759
+ )
760
+ ).orderBy(
761
+ asc(juniorSchedulerRuns.scheduledForMs),
762
+ asc(juniorSchedulerRuns.id)
763
+ );
764
+ return rows.map(parseSqlRunRow).filter(present);
765
+ }
766
+ var SqlSchedulerStore = class {
767
+ constructor(db) {
768
+ this.db = db;
727
769
  }
770
+ db;
728
771
  async saveTask(task) {
729
772
  const next = requireStoredTask(task);
730
- await withLock(this.state, taskLockKey(task.id), async () => {
731
- const current = await getTaskFromState(this.state, task.id);
732
- await this.saveTaskRecord(next, current);
773
+ await withSqlLock(this.db, taskLockKey(task.id), async (db) => {
774
+ const current = await getTaskFromSql(db, task.id);
775
+ await this.saveTaskRecord(db, next, current);
733
776
  });
734
777
  }
735
- async saveTaskRecord(task, current) {
778
+ async saveTaskRecord(db, task, current) {
736
779
  if (current?.status === "blocked" && task.status === "active" && typeof task.nextRunAtMs === "number" && Number.isFinite(task.nextRunAtMs)) {
737
- await this.state.delete(claimKey(task.id, task.nextRunAtMs));
738
- }
739
- await this.state.set(taskKey(task.id), task, SCHEDULER_RECORD_TTL_MS);
740
- if (task.status === "deleted") {
741
- await removeFromIndex(this.state, globalTaskIndexKey(), task.id);
742
- await removeFromIndex(
743
- this.state,
744
- teamTaskIndexKey(task.destination.teamId),
745
- task.id
746
- );
747
- if (current && current.destination.teamId !== task.destination.teamId) {
748
- await removeFromIndex(
749
- this.state,
750
- teamTaskIndexKey(current.destination.teamId),
751
- task.id
752
- );
753
- }
754
- return;
755
- }
756
- await addToIndex(this.state, globalTaskIndexKey(), task.id);
757
- await addToIndex(
758
- this.state,
759
- teamTaskIndexKey(task.destination.teamId),
760
- task.id
761
- );
762
- if (current && current.destination.teamId !== task.destination.teamId) {
763
- await removeFromIndex(
764
- this.state,
765
- teamTaskIndexKey(current.destination.teamId),
766
- task.id
780
+ await db.delete(juniorSchedulerRuns).where(
781
+ and(
782
+ eq(juniorSchedulerRuns.id, buildRunId(task.id, task.nextRunAtMs)),
783
+ eq(juniorSchedulerRuns.status, "blocked")
784
+ )
767
785
  );
768
786
  }
787
+ await upsertSqlTask(db, task);
769
788
  }
770
789
  async getTask(taskId) {
771
- return await getTaskFromState(this.state, taskId);
790
+ return await getTaskFromSql(this.db, taskId);
772
791
  }
773
792
  async listTasks() {
774
- return await listTasksFromState(this.state, globalTaskIndexKey());
793
+ return await listTasksFromSql(this.db);
775
794
  }
776
795
  async listTasksForTeam(teamId) {
777
- return await listTasksFromState(this.state, teamTaskIndexKey(teamId));
796
+ return await listTasksForTeamFromSql(this.db, teamId);
778
797
  }
779
798
  async claimDueRun(args) {
780
- const ids = await getIndex(this.state, globalTaskIndexKey());
781
- for (const id of ids) {
782
- const task = await this.getTask(id);
783
- if (!task || !isDueTask(task, args.nowMs)) {
784
- continue;
785
- }
786
- const scheduledForMs = getDueRunAtMs(task, args.nowMs);
787
- if (scheduledForMs === void 0) {
788
- continue;
789
- }
790
- const runId = buildRunId(task.id, scheduledForMs);
791
- const tryClaimActiveRun = async () => await this.state.setIfNotExists(
792
- activeRunKey(task.id),
793
- { claimedAtMs: args.nowMs, runId, scheduledForMs },
794
- CLAIM_TTL_MS
799
+ return await withSqlLock(this.db, "junior:scheduler:claim", async (db) => {
800
+ const rows = await db.select({ record: juniorSchedulerTasks.record }).from(juniorSchedulerTasks).where(
801
+ and(
802
+ eq(juniorSchedulerTasks.status, "active"),
803
+ or(
804
+ and(
805
+ isNotNull(juniorSchedulerTasks.runNowAtMs),
806
+ lte(juniorSchedulerTasks.runNowAtMs, args.nowMs)
807
+ ),
808
+ and(
809
+ isNotNull(juniorSchedulerTasks.nextRunAtMs),
810
+ lte(juniorSchedulerTasks.nextRunAtMs, args.nowMs)
811
+ )
812
+ )
813
+ )
814
+ ).orderBy(
815
+ asc(juniorSchedulerTasks.createdAtMs),
816
+ asc(juniorSchedulerTasks.id)
795
817
  );
796
- let activeClaimed = await tryClaimActiveRun();
797
- if (!activeClaimed) {
798
- if (await clearStaleActiveRun(this.state, task.id, args.nowMs)) {
799
- activeClaimed = await tryClaimActiveRun();
818
+ for (const row of rows) {
819
+ const task = parseSqlTaskRow(row);
820
+ if (!task) {
821
+ continue;
800
822
  }
801
- if (!activeClaimed) {
823
+ const scheduledForMs = getDueRunAtMs(task, args.nowMs);
824
+ if (scheduledForMs === void 0) {
802
825
  continue;
803
826
  }
804
- }
805
- if (isMissedRunTooOld({ nowMs: args.nowMs, scheduledForMs })) {
806
- await this.skipMissedRun({ nowMs: args.nowMs, scheduledForMs, task });
807
- await clearActiveRun(this.state, task.id, runId);
808
- continue;
809
- }
810
- const tryClaimScheduledSlot = async () => await this.state.setIfNotExists(
811
- claimKey(task.id, scheduledForMs),
812
- { claimedAtMs: args.nowMs },
813
- CLAIM_TTL_MS
814
- );
815
- let claimed = await tryClaimScheduledSlot();
816
- if (!claimed) {
817
- const existingRun = await this.getRun(runId);
818
- if (isStalePendingRun(existingRun, args.nowMs)) {
819
- await clearActiveRun(this.state, task.id, runId);
820
- await this.state.delete(claimKey(task.id, scheduledForMs));
821
- activeClaimed = await tryClaimActiveRun();
822
- claimed = activeClaimed ? await tryClaimScheduledSlot() : false;
827
+ const runId = buildRunId(task.id, scheduledForMs);
828
+ const incompleteRuns = await listIncompleteRunsForTasksFromSql(db, [
829
+ task
830
+ ]);
831
+ const incompleteRun = incompleteRuns.find((run2) => run2.id === runId);
832
+ const blockingRun = incompleteRuns.find(
833
+ (run2) => run2.id !== runId && !isStalePendingRun(run2, args.nowMs)
834
+ );
835
+ if (blockingRun) {
836
+ continue;
837
+ }
838
+ if (incompleteRun) {
839
+ if (!isStalePendingRun(incompleteRun, args.nowMs)) {
840
+ continue;
841
+ }
842
+ const reclaimed = {
843
+ ...incompleteRun,
844
+ attempt: incompleteRun.attempt + 1,
845
+ claimedAtMs: args.nowMs
846
+ };
847
+ await upsertSqlRun(db, reclaimed);
848
+ return reclaimed;
823
849
  }
824
- if (!claimed) {
825
- await clearActiveRun(this.state, task.id, runId);
850
+ const existingRun = await getRunFromSql(db, runId);
851
+ if (existingRun) {
826
852
  continue;
827
853
  }
854
+ if (isMissedRunTooOld({ nowMs: args.nowMs, scheduledForMs })) {
855
+ await this.skipMissedRun(db, {
856
+ nowMs: args.nowMs,
857
+ scheduledForMs,
858
+ task
859
+ });
860
+ continue;
861
+ }
862
+ const run = buildScheduledRun({
863
+ claimedAtMs: args.nowMs,
864
+ scheduledForMs,
865
+ task
866
+ });
867
+ await upsertSqlRun(db, run);
868
+ return run;
828
869
  }
829
- const run = buildScheduledRun({
830
- claimedAtMs: args.nowMs,
831
- scheduledForMs,
832
- task
833
- });
834
- await this.state.set(runKey(run.id), run, SCHEDULED_RUN_TTL_MS);
835
- return run;
836
- }
837
- return void 0;
838
- }
839
- async skipMissedRun(args) {
840
- await withLock(this.state, taskLockKey(args.task.id), async () => {
841
- const current = await getTaskFromState(this.state, args.task.id) ?? void 0;
842
- if (!current || current.status !== "active" || getDueRunAtMs(current, args.nowMs) !== args.scheduledForMs) {
843
- return;
844
- }
845
- const duplicateOf = await this.findStaleRecoveryCanonicalTask(current);
846
- const errorMessage = duplicateOf ? `Duplicate stale scheduled task was skipped without dispatch. Canonical task: ${duplicateOf.id}.` : "Scheduled occurrence was more than 24 hours late and was skipped without dispatch.";
847
- await this.state.set(
848
- runKey(buildRunId(current.id, args.scheduledForMs)),
849
- buildSkippedScheduledRun({
850
- completedAtMs: args.nowMs,
851
- errorMessage,
852
- scheduledForMs: args.scheduledForMs,
853
- task: current
854
- }),
855
- SCHEDULED_RUN_TTL_MS
856
- );
857
- const isRunNow = current.runNowAtMs === args.scheduledForMs;
858
- let nextRunAtMs;
859
- if (!duplicateOf) {
860
- nextRunAtMs = isRunNow && current.nextRunAtMs !== args.scheduledForMs ? current.nextRunAtMs : current.schedule.kind === "recurring" ? getNextRunAtMs(current, args.scheduledForMs, args.nowMs) : void 0;
861
- }
862
- const nextStatus = nextRunAtMs ? "active" : "paused";
863
- await this.saveTaskRecord(
864
- {
865
- ...current,
866
- nextRunAtMs,
867
- runNowAtMs: isRunNow ? void 0 : current.runNowAtMs,
868
- status: nextStatus,
869
- statusReason: nextStatus === "paused" ? errorMessage : void 0,
870
- updatedAtMs: args.nowMs,
871
- version: current.version + 1
872
- },
873
- current
874
- );
870
+ return void 0;
875
871
  });
876
872
  }
877
- async findStaleRecoveryCanonicalTask(task) {
878
- const fingerprint = taskDedupeFingerprint(task);
879
- const ids = await getIndex(
880
- this.state,
881
- teamTaskIndexKey(task.destination.teamId)
873
+ async skipMissedRun(db, args) {
874
+ const current = await getTaskFromSql(db, args.task.id);
875
+ if (!current || current.status !== "active" || getDueRunAtMs(current, args.nowMs) !== args.scheduledForMs) {
876
+ return;
877
+ }
878
+ const duplicateOf = await this.findStaleRecoveryCanonicalTask(db, current);
879
+ const errorMessage = duplicateOf ? `Duplicate stale scheduled task was skipped without dispatch. Canonical task: ${duplicateOf.id}.` : "Scheduled occurrence was more than 24 hours late and was skipped without dispatch.";
880
+ await upsertSqlRun(
881
+ db,
882
+ buildSkippedScheduledRun({
883
+ completedAtMs: args.nowMs,
884
+ errorMessage,
885
+ scheduledForMs: args.scheduledForMs,
886
+ task: current
887
+ })
882
888
  );
883
- const tasks = await Promise.all(
884
- ids.filter((id) => id !== task.id).map((id) => this.getTask(id))
889
+ const isRunNow = current.runNowAtMs === args.scheduledForMs;
890
+ let nextRunAtMs;
891
+ if (!duplicateOf) {
892
+ nextRunAtMs = isRunNow && current.nextRunAtMs !== args.scheduledForMs ? current.nextRunAtMs : current.schedule.kind === "recurring" ? getNextRunAtMs(current, args.scheduledForMs, args.nowMs) : void 0;
893
+ }
894
+ const nextStatus = nextRunAtMs ? "active" : "paused";
895
+ await this.saveTaskRecord(
896
+ db,
897
+ {
898
+ ...current,
899
+ nextRunAtMs,
900
+ runNowAtMs: isRunNow ? void 0 : current.runNowAtMs,
901
+ status: nextStatus,
902
+ statusReason: nextStatus === "paused" ? errorMessage : void 0,
903
+ updatedAtMs: args.nowMs
904
+ },
905
+ current
885
906
  );
886
- return tasks.filter((candidate) => Boolean(candidate)).filter(
907
+ }
908
+ async findStaleRecoveryCanonicalTask(db, task) {
909
+ const fingerprint = taskDedupeFingerprint(task);
910
+ const tasks = await listTasksForTeamFromSql(db, task.destination.teamId);
911
+ return tasks.filter((candidate) => candidate.id !== task.id).filter(
887
912
  (candidate) => candidate.status === "active" && isEarlierTask(candidate, task) && taskDedupeFingerprint(candidate) === fingerprint
888
913
  ).sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id)).at(0);
889
914
  }
890
915
  async getRun(runId) {
891
- return await getRunFromState(this.state, runId);
916
+ return await getRunFromSql(this.db, runId);
892
917
  }
893
918
  async listIncompleteRuns() {
894
- const tasks = await this.listTasks();
895
- return await listIncompleteRunsForTasksFromState(this.state, tasks);
919
+ return await listIncompleteRunsForTasksFromSql(
920
+ this.db,
921
+ await this.listTasks()
922
+ );
923
+ }
924
+ async listIncompleteRunsForTasks(tasks) {
925
+ return await listIncompleteRunsForTasksFromSql(this.db, tasks);
896
926
  }
897
927
  async markRunDispatched(args) {
898
928
  return await this.updateRun(
@@ -915,13 +945,10 @@ var PluginStateSchedulerStore = class {
915
945
  status: "completed"
916
946
  } : void 0
917
947
  );
918
- if (next) {
919
- await clearActiveRun(this.state, next.taskId, next.id);
920
- }
921
948
  return next;
922
949
  }
923
950
  async markRunFailed(args) {
924
- const next = await this.updateRun(
951
+ return await this.updateRun(
925
952
  args.runId,
926
953
  (run) => canFinishRun(run, args.startedAtMs) ? {
927
954
  ...run,
@@ -930,13 +957,9 @@ var PluginStateSchedulerStore = class {
930
957
  status: "failed"
931
958
  } : void 0
932
959
  );
933
- if (next) {
934
- await clearActiveRun(this.state, next.taskId, next.id);
935
- }
936
- return next;
937
960
  }
938
961
  async markRunSkipped(args) {
939
- const next = await this.updateRun(
962
+ return await this.updateRun(
940
963
  args.runId,
941
964
  (run) => run.status === "pending" ? {
942
965
  ...run,
@@ -945,13 +968,9 @@ var PluginStateSchedulerStore = class {
945
968
  status: "skipped"
946
969
  } : void 0
947
970
  );
948
- if (next) {
949
- await clearActiveRun(this.state, next.taskId, next.id);
950
- }
951
- return next;
952
971
  }
953
972
  async markRunBlocked(args) {
954
- const next = await this.updateRun(
973
+ return await this.updateRun(
955
974
  args.runId,
956
975
  (run) => canFinishRun(run, args.startedAtMs) ? {
957
976
  ...run,
@@ -960,14 +979,10 @@ var PluginStateSchedulerStore = class {
960
979
  status: "blocked"
961
980
  } : void 0
962
981
  );
963
- if (next) {
964
- await clearActiveRun(this.state, next.taskId, next.id);
965
- }
966
- return next;
967
982
  }
968
983
  async updateTaskAfterRun(args) {
969
- await withLock(this.state, taskLockKey(args.run.taskId), async () => {
970
- const current = await getTaskFromState(this.state, args.run.taskId) ?? void 0;
984
+ await withSqlLock(this.db, taskLockKey(args.run.taskId), async (db) => {
985
+ const current = await getTaskFromSql(db, args.run.taskId);
971
986
  if (!current || current.status === "deleted") {
972
987
  return;
973
988
  }
@@ -982,6 +997,7 @@ var PluginStateSchedulerStore = class {
982
997
  );
983
998
  }
984
999
  await this.saveTaskRecord(
1000
+ db,
985
1001
  {
986
1002
  ...current,
987
1003
  lastRunAtMs: args.run.scheduledForMs,
@@ -989,8 +1005,7 @@ var PluginStateSchedulerStore = class {
989
1005
  runNowAtMs: void 0,
990
1006
  status: args.status === "blocked" ? "blocked" : nextRunAtMs2 ? current.status : "paused",
991
1007
  statusReason: args.status === "blocked" ? args.errorMessage : void 0,
992
- updatedAtMs: args.nowMs,
993
- version: current.version + 1
1008
+ updatedAtMs: args.nowMs
994
1009
  },
995
1010
  current
996
1011
  );
@@ -998,11 +1013,11 @@ var PluginStateSchedulerStore = class {
998
1013
  }
999
1014
  if (current.status !== "active" || current.nextRunAtMs !== args.run.scheduledForMs) {
1000
1015
  await this.saveTaskRecord(
1016
+ db,
1001
1017
  {
1002
1018
  ...current,
1003
1019
  lastRunAtMs: args.run.scheduledForMs,
1004
- updatedAtMs: args.nowMs,
1005
- version: current.version + 1
1020
+ updatedAtMs: args.nowMs
1006
1021
  },
1007
1022
  current
1008
1023
  );
@@ -1010,58 +1025,153 @@ var PluginStateSchedulerStore = class {
1010
1025
  }
1011
1026
  const nextRunAtMs = args.status === "blocked" ? void 0 : getNextRunAtMs(current, args.run.scheduledForMs, args.nowMs);
1012
1027
  await this.saveTaskRecord(
1028
+ db,
1013
1029
  {
1014
1030
  ...current,
1015
1031
  lastRunAtMs: args.run.scheduledForMs,
1016
1032
  nextRunAtMs,
1017
1033
  status: args.status === "blocked" ? "blocked" : nextRunAtMs ? "active" : "paused",
1018
1034
  statusReason: args.status === "blocked" ? args.errorMessage : void 0,
1019
- updatedAtMs: args.nowMs,
1020
- version: current.version + 1
1035
+ updatedAtMs: args.nowMs
1021
1036
  },
1022
1037
  current
1023
1038
  );
1024
1039
  });
1025
1040
  }
1026
1041
  async updateRun(runId, update) {
1027
- return await withLock(this.state, indexLockKey(runKey(runId)), async () => {
1028
- const current = await this.getRun(runId);
1029
- if (!current) {
1030
- return void 0;
1031
- }
1032
- const next = update(current);
1033
- if (!next) {
1034
- return void 0;
1042
+ return await withSqlLock(
1043
+ this.db,
1044
+ indexLockKey(runKey(runId)),
1045
+ async (db) => {
1046
+ const current = await getRunFromSql(db, runId);
1047
+ if (!current) {
1048
+ return void 0;
1049
+ }
1050
+ const next = update(current);
1051
+ if (!next) {
1052
+ return void 0;
1053
+ }
1054
+ await upsertSqlRun(db, next);
1055
+ return next;
1035
1056
  }
1036
- await this.state.set(runKey(runId), next, SCHEDULED_RUN_TTL_MS);
1037
- return next;
1038
- });
1057
+ );
1039
1058
  }
1040
1059
  };
1041
- function createSchedulerStore(state) {
1042
- return new PluginStateSchedulerStore(state);
1060
+ function createSchedulerSqlStore(db) {
1061
+ return new SqlSchedulerStore(db);
1062
+ }
1063
+ function createSchedulerOperationalSqlStore(db) {
1064
+ return new SqlSchedulerStore(db);
1065
+ }
1066
+ async function migrateSchedulerStateToSql(args) {
1067
+ const store = createSchedulerSqlStore(args.db);
1068
+ const ids = await getIndex(args.state, globalTaskIndexKey());
1069
+ let existing = 0;
1070
+ let migrated = 0;
1071
+ let missing = 0;
1072
+ const migratedTasks = [];
1073
+ for (const id of ids) {
1074
+ const task = await getTaskFromState(args.state, id);
1075
+ if (!task) {
1076
+ missing += 1;
1077
+ continue;
1078
+ }
1079
+ migratedTasks.push(task);
1080
+ if (await store.getTask(task.id)) {
1081
+ existing += 1;
1082
+ continue;
1083
+ }
1084
+ await store.saveTask(task);
1085
+ migrated += 1;
1086
+ }
1087
+ const runs = await listIncompleteRunsForTasksFromState(
1088
+ args.state,
1089
+ migratedTasks
1090
+ );
1091
+ for (const run of runs) {
1092
+ if (await store.getRun(run.id)) {
1093
+ existing += 1;
1094
+ continue;
1095
+ }
1096
+ await upsertSqlRun(args.db, run);
1097
+ migrated += 1;
1098
+ }
1099
+ return {
1100
+ existing,
1101
+ migrated,
1102
+ missing,
1103
+ scanned: ids.length + runs.length
1104
+ };
1105
+ }
1106
+
1107
+ // src/identity.ts
1108
+ var SLACK_USER_ID_PATTERN = /^[UW][A-Z0-9]{5,}$/;
1109
+ function clean(value) {
1110
+ const normalized = value?.trim();
1111
+ return normalized ? normalized : void 0;
1043
1112
  }
1044
- function createSchedulerOperationalStore(state) {
1045
- return new PluginStateSchedulerOperationalStore(state);
1113
+ function cleanSlackUserId(value) {
1114
+ const normalized = clean(value);
1115
+ return normalized && normalized.toLowerCase() !== "unknown" ? normalized : void 0;
1116
+ }
1117
+ function cleanDisplay(value, slackUserId) {
1118
+ const normalized = clean(value);
1119
+ if (!normalized || normalized.toLowerCase() === "unknown" || normalized === slackUserId) {
1120
+ return void 0;
1121
+ }
1122
+ return SLACK_USER_ID_PATTERN.test(normalized) ? void 0 : normalized;
1123
+ }
1124
+ function sanitizeScheduledTaskPrincipal(principal) {
1125
+ const slackUserId = cleanSlackUserId(principal.slackUserId);
1126
+ if (!slackUserId) {
1127
+ throw new Error("Scheduled task principal requires a Slack user id");
1128
+ }
1129
+ const fullName = cleanDisplay(principal.fullName, slackUserId);
1130
+ const userName = cleanDisplay(principal.userName, slackUserId);
1131
+ return {
1132
+ slackUserId,
1133
+ ...fullName ? { fullName } : {},
1134
+ ...userName ? { userName } : {}
1135
+ };
1136
+ }
1137
+ function scheduledTaskPrincipalLabel(principal) {
1138
+ const author = sanitizeScheduledTaskPrincipal(principal);
1139
+ if (author.fullName && author.userName) {
1140
+ return `${author.fullName} (@${author.userName})`;
1141
+ }
1142
+ if (author.fullName) {
1143
+ return author.fullName;
1144
+ }
1145
+ if (author.userName) {
1146
+ return `@${author.userName}`;
1147
+ }
1148
+ return `Slack User ${author.slackUserId}`;
1046
1149
  }
1047
1150
 
1048
1151
  // src/schedule-tools.ts
1049
1152
  import { randomUUID } from "crypto";
1050
1153
  import { Type } from "@sinclair/typebox";
1051
1154
  import {
1052
- AgentPluginToolInputError,
1053
- agentPluginCredentialSubjectSchema as agentPluginCredentialSubjectSchema2,
1054
- destinationSchema as destinationSchema2,
1055
- isSlackDestination as isSlackDestination2
1155
+ PluginToolInputError,
1156
+ pluginCredentialSubjectSchema as pluginCredentialSubjectSchema2,
1157
+ sourceSchema
1056
1158
  } from "@sentry/junior-plugin-api";
1159
+
1160
+ // src/types.ts
1161
+ var SCHEDULED_TASK_SYSTEM_ACTOR = Object.freeze({
1162
+ type: "system",
1163
+ id: "scheduled-task"
1164
+ });
1165
+
1166
+ // src/schedule-tools.ts
1057
1167
  var TASK_ID_PREFIX = "sched";
1058
1168
  var MAX_LISTED_TASKS = 50;
1059
1169
  var DEFAULT_SCHEDULE_TIMEZONE = "America/Los_Angeles";
1060
1170
  function throwToolInputError(error) {
1061
- throw new AgentPluginToolInputError(error);
1171
+ throw new PluginToolInputError(error);
1062
1172
  }
1063
1173
  function requireActiveConversation(context) {
1064
- const parsed = destinationSchema2.safeParse(context.source);
1174
+ const parsed = sourceSchema.safeParse(context.source);
1065
1175
  if (!parsed.success) {
1066
1176
  const source = context.source;
1067
1177
  const issues = parsed.error.issues;
@@ -1081,10 +1191,14 @@ function requireActiveConversation(context) {
1081
1191
  }
1082
1192
  throwToolInputError("No active Slack conversation is available.");
1083
1193
  }
1084
- if (!isSlackDestination2(parsed.data)) {
1194
+ if (parsed.data.platform !== "slack") {
1085
1195
  throwToolInputError("No active Slack conversation is available.");
1086
1196
  }
1087
- return parsed.data;
1197
+ return {
1198
+ platform: "slack",
1199
+ teamId: parsed.data.teamId,
1200
+ channelId: parsed.data.channelId
1201
+ };
1088
1202
  }
1089
1203
  function requireRequester(context) {
1090
1204
  if (context.requester?.platform !== "slack") {
@@ -1125,7 +1239,7 @@ function getCredentialSubject(args) {
1125
1239
  if (!args.subject) {
1126
1240
  return void 0;
1127
1241
  }
1128
- const subject = agentPluginCredentialSubjectSchema2.safeParse(args.subject);
1242
+ const subject = pluginCredentialSubjectSchema2.safeParse(args.subject);
1129
1243
  if (!subject.success) {
1130
1244
  throwToolInputError("Active Slack credential subject is invalid.");
1131
1245
  }
@@ -1141,9 +1255,7 @@ function sameDestination(task, destination) {
1141
1255
  }
1142
1256
  async function getWritableTask(args) {
1143
1257
  const destination = requireActiveConversation(args.context);
1144
- const task = await createSchedulerStore(args.context.state).getTask(
1145
- args.taskId
1146
- );
1258
+ const task = await schedulerStore(args.context).getTask(args.taskId);
1147
1259
  if (!task || task.status === "deleted") {
1148
1260
  throwToolInputError(
1149
1261
  "Scheduled task was not found in the active Slack conversation."
@@ -1179,13 +1291,15 @@ function compactTask(task) {
1179
1291
  allowed_when: task.credentialSubject.allowedWhen
1180
1292
  } : null,
1181
1293
  last_run_at: task.lastRunAtMs ? new Date(task.lastRunAtMs).toISOString() : null,
1182
- run_now_at: task.runNowAtMs ? new Date(task.runNowAtMs).toISOString() : null,
1183
- version: task.version
1294
+ run_now_at: task.runNowAtMs ? new Date(task.runNowAtMs).toISOString() : null
1184
1295
  };
1185
1296
  }
1186
1297
  function buildTaskId() {
1187
1298
  return `${TASK_ID_PREFIX}_${randomUUID()}`;
1188
1299
  }
1300
+ function schedulerStore(context) {
1301
+ return context.store;
1302
+ }
1189
1303
  function normalizeStatus(value) {
1190
1304
  if (value === "active" || value === "paused" || value === "blocked") {
1191
1305
  return value;
@@ -1230,6 +1344,20 @@ function validateRecurringFrequencyLimit(input) {
1230
1344
  );
1231
1345
  }
1232
1346
  }
1347
+ function validateCreateScheduleKind(input) {
1348
+ if (input.schedule_kind === void 0) {
1349
+ throwToolInputError("Provide schedule_kind as one_off or recurring.");
1350
+ }
1351
+ if (input.schedule_kind !== "one_off" && input.schedule_kind !== "recurring") {
1352
+ throwToolInputError("schedule_kind must be one_off or recurring.");
1353
+ }
1354
+ if (input.schedule_kind === "one_off" && input.recurrence !== void 0) {
1355
+ throwToolInputError("Omit recurrence when schedule_kind is one_off.");
1356
+ }
1357
+ if (input.schedule_kind === "recurring" && (input.recurrence === void 0 || input.recurrence === null)) {
1358
+ throwToolInputError("Provide recurrence when schedule_kind is recurring.");
1359
+ }
1360
+ }
1233
1361
  function shouldRebuildRecurrence(input) {
1234
1362
  return input.next_run_at !== void 0 || input.recurrence !== void 0 || input.timezone !== void 0;
1235
1363
  }
@@ -1256,11 +1384,17 @@ function parseNextRunAtMs(nextRunAtIso) {
1256
1384
  }
1257
1385
  function createSlackScheduleCreateTaskTool(context) {
1258
1386
  return tool({
1259
- description: "Create a future or recurring Junior task in the active Slack conversation. Use only when the user explicitly asks Junior to do work later or on a recurring cadence. Only manage tasks for the active Slack DM or channel; never target threads, other channels, or another user's DM. When the task, schedule, and destination are clear, create it without asking for confirmation; ask only when one of those is ambiguous.",
1387
+ description: "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. Only manage tasks for the active Slack DM or channel; never target threads, other channels, or another user's DM. When the task, schedule, and destination are clear, create it without asking for confirmation; ask only when one of those is ambiguous.",
1260
1388
  executionMode: "sequential",
1261
1389
  inputSchema: Type.Object({
1262
1390
  task: Type.String({ minLength: 1, maxLength: 4e3 }),
1263
1391
  schedule: Type.String({ minLength: 1, maxLength: 300 }),
1392
+ schedule_kind: Type.Union(
1393
+ [Type.Literal("one_off"), Type.Literal("recurring")],
1394
+ {
1395
+ description: "Required schedule classification. Use one_off for one-time reminders or one-time scheduled work. Use recurring only when the user explicitly asks for a repeating schedule."
1396
+ }
1397
+ ),
1264
1398
  timezone: Type.Optional(
1265
1399
  Type.String({
1266
1400
  minLength: 1,
@@ -1283,7 +1417,7 @@ function createSlackScheduleCreateTaskTool(context) {
1283
1417
  Type.Literal("yearly")
1284
1418
  ],
1285
1419
  {
1286
- description: "Provide only for explicitly repeating schedules; omit for one-time requests like 'in 1 minute', 'tomorrow', or a specific date. Recurring tasks run at most once per day: use daily, weekly, monthly, or yearly only."
1420
+ description: "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."
1287
1421
  }
1288
1422
  )
1289
1423
  )
@@ -1293,6 +1427,7 @@ function createSlackScheduleCreateTaskTool(context) {
1293
1427
  const requester = requireRequester(context);
1294
1428
  const nowMs = Date.now();
1295
1429
  const timezone = input.timezone ?? getDefaultScheduleTimezone();
1430
+ validateCreateScheduleKind(input);
1296
1431
  validateRecurringFrequencyLimit(input);
1297
1432
  if (!isValidTimeZone(timezone)) {
1298
1433
  throwToolInputError("timezone must be a valid IANA time zone.");
@@ -1331,10 +1466,9 @@ function createSlackScheduleCreateTaskTool(context) {
1331
1466
  status: "active",
1332
1467
  task: {
1333
1468
  text: input.task
1334
- },
1335
- version: 1
1469
+ }
1336
1470
  };
1337
- await createSchedulerStore(context.state).saveTask(task);
1471
+ await schedulerStore(context).saveTask(task);
1338
1472
  return {
1339
1473
  ok: true,
1340
1474
  task: compactTask(task)
@@ -1349,7 +1483,7 @@ function createSlackScheduleListTasksTool(context) {
1349
1483
  inputSchema: Type.Object({}),
1350
1484
  execute: async () => {
1351
1485
  const destination = requireActiveConversation(context);
1352
- const tasks = await createSchedulerStore(context.state).listTasksForTeam(
1486
+ const tasks = await schedulerStore(context).listTasksForTeam(
1353
1487
  destination.teamId
1354
1488
  );
1355
1489
  const matching = tasks.filter(
@@ -1454,10 +1588,9 @@ function createSlackScheduleUpdateTaskTool(context) {
1454
1588
  kind: recurrence ? "recurring" : "one_off",
1455
1589
  recurrence
1456
1590
  },
1457
- task: input.task ? { text: input.task } : lookup.task,
1458
- version: lookup.version + 1
1591
+ task: input.task ? { text: input.task } : lookup.task
1459
1592
  };
1460
- await createSchedulerStore(context.state).saveTask(next);
1593
+ await schedulerStore(context).saveTask(next);
1461
1594
  return {
1462
1595
  ok: true,
1463
1596
  task: compactTask(next)
@@ -1482,10 +1615,9 @@ function createSlackScheduleDeleteTaskTool(context) {
1482
1615
  updatedAtMs: Date.now(),
1483
1616
  status: "deleted",
1484
1617
  nextRunAtMs: void 0,
1485
- runNowAtMs: void 0,
1486
- version: lookup.version + 1
1618
+ runNowAtMs: void 0
1487
1619
  };
1488
- await createSchedulerStore(context.state).saveTask(next);
1620
+ await schedulerStore(context).saveTask(next);
1489
1621
  return {
1490
1622
  ok: true,
1491
1623
  task: compactTask(next)
@@ -1514,10 +1646,9 @@ function createSlackScheduleRunTaskNowTool(context) {
1514
1646
  const next = {
1515
1647
  ...lookup,
1516
1648
  updatedAtMs: nowMs,
1517
- runNowAtMs: nowMs,
1518
- version: lookup.version + 1
1649
+ runNowAtMs: nowMs
1519
1650
  };
1520
- await createSchedulerStore(context.state).saveTask(next);
1651
+ await schedulerStore(context).saveTask(next);
1521
1652
  return {
1522
1653
  ok: true,
1523
1654
  task: compactTask(next)
@@ -1529,6 +1660,43 @@ function createSlackScheduleRunTaskNowTool(context) {
1529
1660
  // src/plugin.ts
1530
1661
  var SCHEDULER_HEARTBEAT_LIMIT = 10;
1531
1662
  var DASHBOARD_TABLE_LIMIT = 5;
1663
+ function singleLineMetadataValue(value) {
1664
+ return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
1665
+ }
1666
+ function buildScheduledTaskDispatchMetadata(args) {
1667
+ const { run, task } = args;
1668
+ const creator = sanitizeScheduledTaskPrincipal(task.createdBy);
1669
+ if (!task.task.text?.trim()) {
1670
+ throw new Error("Scheduled task text is required");
1671
+ }
1672
+ return {
1673
+ creatorSlackUserId: creator.slackUserId,
1674
+ runId: run.id,
1675
+ schedule: singleLineMetadataValue(task.schedule.description),
1676
+ scheduleKind: task.schedule.kind,
1677
+ scheduledFor: new Date(run.scheduledForMs).toISOString(),
1678
+ runningAt: new Date(args.nowMs).toISOString(),
1679
+ taskId: task.id,
1680
+ timezone: task.schedule.timezone,
1681
+ ...task.schedule.recurrence ? {
1682
+ recurrenceFrequency: task.schedule.recurrence.frequency,
1683
+ recurrenceInterval: String(task.schedule.recurrence.interval),
1684
+ recurrenceStartDate: task.schedule.recurrence.startDate
1685
+ } : {}
1686
+ };
1687
+ }
1688
+ function scheduledTaskDispatchSource(task) {
1689
+ return createSlackSource({
1690
+ teamId: task.destination.teamId,
1691
+ channelId: task.destination.channelId
1692
+ });
1693
+ }
1694
+ function schedulerStore2(ctx) {
1695
+ return createSchedulerSqlStore(ctx.db);
1696
+ }
1697
+ function schedulerOperationalStore(ctx) {
1698
+ return createSchedulerOperationalSqlStore(ctx.db);
1699
+ }
1532
1700
  function shouldSkipRun(task, run) {
1533
1701
  if (task.status === "deleted") {
1534
1702
  return `Scheduled task ${task.id} was deleted before the run started.`;
@@ -1544,13 +1712,9 @@ function shouldSkipRun(task, run) {
1544
1712
  function createSchedulerToolContext(ctx) {
1545
1713
  return {
1546
1714
  credentialSubject: ctx.slack?.credentialSubject,
1547
- source: ctx.source.platform === "slack" ? {
1548
- platform: "slack",
1549
- teamId: ctx.source.teamId,
1550
- channelId: ctx.source.channelId
1551
- } : void 0,
1715
+ source: ctx.source.platform === "slack" ? ctx.source : void 0,
1552
1716
  requester: ctx.requester?.platform === "slack" ? ctx.requester : void 0,
1553
- state: ctx.state,
1717
+ store: schedulerStore2(ctx),
1554
1718
  userText: ctx.userText
1555
1719
  };
1556
1720
  }
@@ -1800,7 +1964,7 @@ function createSchedulerPlugin() {
1800
1964
  displayName: "Scheduler",
1801
1965
  description: "Scheduled Junior task management and heartbeat dispatch"
1802
1966
  },
1803
- legacyStatePrefixes: ["junior:scheduler"],
1967
+ packageName: "@sentry/junior-scheduler",
1804
1968
  hooks: {
1805
1969
  tools(ctx) {
1806
1970
  if (ctx.source.platform !== "slack" || ctx.requester?.platform !== "slack") {
@@ -1816,7 +1980,7 @@ function createSchedulerPlugin() {
1816
1980
  };
1817
1981
  },
1818
1982
  async heartbeat(ctx) {
1819
- const store = createSchedulerStore(ctx.state);
1983
+ const store = schedulerStore2(ctx);
1820
1984
  let processedCount = 0;
1821
1985
  let dispatchCount = 0;
1822
1986
  for (const run of await store.listIncompleteRuns()) {
@@ -1842,7 +2006,7 @@ function createSchedulerPlugin() {
1842
2006
  processedCount += 1;
1843
2007
  }
1844
2008
  }
1845
- for (let index = processedCount; index < SCHEDULER_HEARTBEAT_LIMIT; index += 1) {
2009
+ for (let index2 = processedCount; index2 < SCHEDULER_HEARTBEAT_LIMIT; index2 += 1) {
1846
2010
  const run = await store.claimDueRun({ nowMs: ctx.nowMs });
1847
2011
  if (!run) {
1848
2012
  break;
@@ -1865,15 +2029,15 @@ function createSchedulerPlugin() {
1865
2029
  });
1866
2030
  continue;
1867
2031
  }
1868
- let prompt;
2032
+ let dispatchMetadata;
1869
2033
  try {
1870
- prompt = buildScheduledTaskRunPrompt({
2034
+ dispatchMetadata = buildScheduledTaskDispatchMetadata({
1871
2035
  nowMs: ctx.nowMs,
1872
2036
  run,
1873
2037
  task
1874
2038
  });
1875
2039
  } catch (error) {
1876
- const errorMessage = error instanceof Error ? `Scheduled task prompt could not be built: ${error.message}` : "Scheduled task prompt could not be built.";
2040
+ const errorMessage = error instanceof Error ? `Scheduled task dispatch metadata could not be built: ${error.message}` : "Scheduled task dispatch metadata could not be built.";
1877
2041
  await blockClaimedRun({
1878
2042
  errorMessage,
1879
2043
  nowMs: ctx.nowMs,
@@ -1888,11 +2052,9 @@ function createSchedulerPlugin() {
1888
2052
  idempotencyKey: run.id,
1889
2053
  ...task.credentialSubject ? { credentialSubject: task.credentialSubject } : {},
1890
2054
  destination: task.destination,
1891
- input: prompt,
1892
- metadata: {
1893
- runId: run.id,
1894
- taskId: task.id
1895
- }
2055
+ input: task.task.text,
2056
+ metadata: dispatchMetadata,
2057
+ source: scheduledTaskDispatchSource(task)
1896
2058
  });
1897
2059
  } catch (error) {
1898
2060
  const errorMessage = error instanceof Error ? `Scheduled task dispatch could not be created: ${error.message}` : "Scheduled task dispatch could not be created.";
@@ -1917,7 +2079,13 @@ function createSchedulerPlugin() {
1917
2079
  async operationalReport(ctx) {
1918
2080
  return buildSchedulerOperationalReport({
1919
2081
  nowMs: ctx.nowMs,
1920
- store: createSchedulerOperationalStore(ctx.state)
2082
+ store: schedulerOperationalStore(ctx)
2083
+ });
2084
+ },
2085
+ async migrateStorage(ctx) {
2086
+ return await migrateSchedulerStateToSql({
2087
+ db: ctx.db,
2088
+ state: ctx.state
1921
2089
  });
1922
2090
  }
1923
2091
  }
@@ -1925,13 +2093,14 @@ function createSchedulerPlugin() {
1925
2093
  }
1926
2094
  var schedulerPlugin = createSchedulerPlugin;
1927
2095
  export {
1928
- buildScheduledTaskRunPrompt,
2096
+ createSchedulerOperationalSqlStore,
1929
2097
  createSchedulerPlugin,
1930
- createSchedulerStore,
2098
+ createSchedulerSqlStore,
1931
2099
  createSlackScheduleCreateTaskTool,
1932
2100
  createSlackScheduleDeleteTaskTool,
1933
2101
  createSlackScheduleListTasksTool,
1934
2102
  createSlackScheduleRunTaskNowTool,
1935
2103
  createSlackScheduleUpdateTaskTool,
2104
+ migrateSchedulerStateToSql,
1936
2105
  schedulerPlugin
1937
2106
  };