@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/dist/index.js CHANGED
@@ -3,131 +3,13 @@ import {
3
3
  defineJuniorPlugin
4
4
  } from "@sentry/junior-plugin-api";
5
5
 
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
6
  // src/store.ts
126
7
  import {
127
- agentPluginCredentialSubjectSchema,
8
+ pluginCredentialSubjectSchema,
128
9
  destinationSchema,
129
10
  isSlackDestination
130
11
  } from "@sentry/junior-plugin-api";
12
+ import { z } from "zod";
131
13
 
132
14
  // src/cadence.ts
133
15
  function parseScheduleTimestamp(value) {
@@ -483,7 +365,81 @@ var SCHEDULED_RUN_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
483
365
  var CLAIM_TTL_MS = 6 * 60 * 60 * 1e3;
484
366
  var PENDING_CLAIM_STALE_MS = 6e4;
485
367
  var MISSED_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
486
- var LOCK_TTL_MS = 1e4;
368
+ var SQL_INCOMPLETE_RUN_STATUSES = ["pending", "running"];
369
+ var slackDestinationSchema = destinationSchema.refine(isSlackDestination);
370
+ var taskPrincipalSchema = z.object({
371
+ slackUserId: z.string(),
372
+ fullName: z.string().optional(),
373
+ userName: z.string().optional()
374
+ }).strict();
375
+ var recurrenceSchema = z.object({
376
+ dayOfMonth: z.number().optional(),
377
+ frequency: z.enum(["daily", "weekly", "monthly", "yearly"]),
378
+ interval: z.number(),
379
+ month: z.number().optional(),
380
+ startDate: z.string(),
381
+ time: z.object({
382
+ hour: z.number(),
383
+ minute: z.number()
384
+ }).strict(),
385
+ weekdays: z.array(z.number()).optional()
386
+ }).strict();
387
+ var taskScheduleSchema = z.object({
388
+ description: z.string(),
389
+ kind: z.enum(["one_off", "recurring"]),
390
+ recurrence: recurrenceSchema.optional(),
391
+ timezone: z.string()
392
+ }).strict();
393
+ var taskSpecSchema = z.object({
394
+ text: z.string()
395
+ }).strict();
396
+ var taskRecordSchema = z.object({
397
+ id: z.string(),
398
+ conversationAccess: z.object({
399
+ audience: z.enum(["direct", "group", "channel"]),
400
+ visibility: z.enum(["private", "public", "unknown"])
401
+ }).strict().optional(),
402
+ createdAtMs: z.number(),
403
+ createdBy: taskPrincipalSchema,
404
+ credentialSubject: pluginCredentialSubjectSchema.optional(),
405
+ destination: slackDestinationSchema,
406
+ executionActor: z.object({
407
+ type: z.literal("system"),
408
+ id: z.string()
409
+ }).strict().optional(),
410
+ lastRunAtMs: z.number().optional(),
411
+ nextRunAtMs: z.number().optional(),
412
+ originalRequest: z.string().optional(),
413
+ runNowAtMs: z.number().optional(),
414
+ schedule: taskScheduleSchema,
415
+ status: z.enum(["active", "paused", "blocked", "deleted"]),
416
+ statusReason: z.string().optional(),
417
+ task: taskSpecSchema,
418
+ updatedAtMs: z.number(),
419
+ version: z.number().optional()
420
+ }).strict();
421
+ var runRecordSchema = z.object({
422
+ id: z.string(),
423
+ attempt: z.number(),
424
+ claimedAtMs: z.number(),
425
+ completedAtMs: z.number().optional(),
426
+ dispatchId: z.string().optional(),
427
+ errorMessage: z.string().optional(),
428
+ idempotencyKey: z.string().optional(),
429
+ resultMessageTs: z.string().optional(),
430
+ scheduledForMs: z.number(),
431
+ startedAtMs: z.number().optional(),
432
+ status: z.enum([
433
+ "pending",
434
+ "running",
435
+ "completed",
436
+ "failed",
437
+ "blocked",
438
+ "skipped"
439
+ ]),
440
+ taskId: z.string(),
441
+ taskVersion: z.number().optional()
442
+ }).strict();
487
443
  function taskKey(taskId) {
488
444
  return `${SCHEDULER_KEY_PREFIX}:task:${taskId}`;
489
445
  }
@@ -493,18 +449,12 @@ function taskLockKey(taskId) {
493
449
  function runKey(runId) {
494
450
  return `${SCHEDULER_KEY_PREFIX}:run:${runId}`;
495
451
  }
496
- function claimKey(taskId, scheduledForMs) {
497
- return `${SCHEDULER_KEY_PREFIX}:claim:${taskId}:${scheduledForMs}`;
498
- }
499
452
  function activeRunKey(taskId) {
500
453
  return `${SCHEDULER_KEY_PREFIX}:active:${taskId}`;
501
454
  }
502
455
  function globalTaskIndexKey() {
503
456
  return `${SCHEDULER_KEY_PREFIX}:tasks`;
504
457
  }
505
- function teamTaskIndexKey(teamId) {
506
- return `${SCHEDULER_KEY_PREFIX}:team:${teamId}:tasks`;
507
- }
508
458
  function indexLockKey(indexKey) {
509
459
  return `${indexKey}:lock`;
510
460
  }
@@ -514,80 +464,18 @@ function buildRunId(taskId, scheduledForMs) {
514
464
  function unique(values) {
515
465
  return [...new Set(values.filter(Boolean))];
516
466
  }
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
467
  async function getIndex(state, key) {
547
468
  const values = await state.get(key) ?? [];
548
469
  return unique(
549
470
  values.filter((value) => typeof value === "string")
550
471
  );
551
472
  }
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
473
  function isFinishedRun(run) {
577
474
  return run.status === "completed" || run.status === "failed" || run.status === "blocked" || run.status === "skipped";
578
475
  }
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
476
  function isStalePendingRun(run, nowMs) {
586
477
  return run?.status === "pending" && run.claimedAtMs + PENDING_CLAIM_STALE_MS <= nowMs;
587
478
  }
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
479
  function getDueRunAtMs(task, nowMs) {
592
480
  if (typeof task.runNowAtMs === "number" && Number.isFinite(task.runNowAtMs) && task.runNowAtMs <= nowMs) {
593
481
  return task.runNowAtMs;
@@ -598,16 +486,13 @@ function getDueRunAtMs(task, nowMs) {
598
486
  return void 0;
599
487
  }
600
488
  function buildScheduledRun(args) {
601
- const idempotencyKey = `${args.task.id}:${args.scheduledForMs}`;
602
489
  return {
603
490
  id: buildRunId(args.task.id, args.scheduledForMs),
604
491
  attempt: 1,
605
492
  claimedAtMs: args.claimedAtMs,
606
- idempotencyKey,
607
493
  scheduledForMs: args.scheduledForMs,
608
494
  status: "pending",
609
- taskId: args.task.id,
610
- taskVersion: args.task.version
495
+ taskId: args.task.id
611
496
  };
612
497
  }
613
498
  function buildSkippedScheduledRun(args) {
@@ -658,23 +543,40 @@ function canFinishRun(run, startedAtMs) {
658
543
  return run.status === "running" && run.startedAtMs === startedAtMs;
659
544
  }
660
545
  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;
546
+ const parsed = taskRecordSchema.safeParse(parseJsonRecord(value));
547
+ return parsed.success ? stripLegacyTaskFields(parsed.data) : void 0;
548
+ }
549
+ function parseStoredRun(value) {
550
+ const parsed = runRecordSchema.safeParse(parseJsonRecord(value));
551
+ return parsed.success ? stripLegacyRunFields(parsed.data) : void 0;
552
+ }
553
+ function stripLegacyTaskFields(task) {
554
+ const { version: _version, ...current } = task;
555
+ return current;
556
+ }
557
+ function stripLegacyRunFields(run) {
558
+ const {
559
+ idempotencyKey: _idempotencyKey,
560
+ taskVersion: _taskVersion,
561
+ ...current
562
+ } = run;
563
+ return current;
564
+ }
565
+ function parseJsonRecord(value) {
566
+ if (typeof value === "string") {
567
+ try {
568
+ return JSON.parse(value);
569
+ } catch {
570
+ return void 0;
571
+ }
668
572
  }
669
- const credentialSubject = record.credentialSubject === void 0 ? void 0 : agentPluginCredentialSubjectSchema.safeParse(record.credentialSubject);
670
- if (credentialSubject && !credentialSubject.success) {
671
- return void 0;
573
+ if (value && typeof value === "object") {
574
+ return value;
672
575
  }
673
- return {
674
- ...record,
675
- destination: destination.data,
676
- ...credentialSubject ? { credentialSubject: credentialSubject.data } : {}
677
- };
576
+ return void 0;
577
+ }
578
+ function present(value) {
579
+ return value !== void 0;
678
580
  }
679
581
  function requireStoredTask(task) {
680
582
  const parsed = parseStoredTask(task);
@@ -686,13 +588,8 @@ function requireStoredTask(task) {
686
588
  async function getTaskFromState(state, taskId) {
687
589
  return parseStoredTask(await state.get(taskKey(taskId)));
688
590
  }
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
591
  async function getRunFromState(state, runId) {
695
- return await state.get(runKey(runId)) ?? void 0;
592
+ return parseStoredRun(await state.get(runKey(runId)));
696
593
  }
697
594
  async function listIncompleteRunsForTasksFromState(state, tasks) {
698
595
  const runs = [];
@@ -708,191 +605,294 @@ async function listIncompleteRunsForTasksFromState(state, tasks) {
708
605
  }
709
606
  return runs;
710
607
  }
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);
721
- }
722
- };
723
- var PluginStateSchedulerStore = class {
724
- state;
725
- constructor(state) {
726
- this.state = state;
608
+ function parseSqlTaskRecord(value) {
609
+ const parsed = taskRecordSchema.safeParse(parseJsonRecord(value));
610
+ return parsed.success ? stripLegacyTaskFields(parsed.data) : void 0;
611
+ }
612
+ function parseSqlTaskRow(row) {
613
+ return parseSqlTaskRecord(row.record);
614
+ }
615
+ function parseSqlRunRow(row) {
616
+ const parsed = runRecordSchema.safeParse(parseJsonRecord(row.record));
617
+ return parsed.success ? stripLegacyRunFields(parsed.data) : void 0;
618
+ }
619
+ function json(value) {
620
+ return JSON.stringify(value);
621
+ }
622
+ async function withSqlLock(db, key, callback) {
623
+ return await db.transaction(async (tx) => {
624
+ await tx.execute("SELECT pg_advisory_xact_lock(hashtext($1))", [key]);
625
+ return await callback(tx);
626
+ });
627
+ }
628
+ async function upsertSqlTask(db, task) {
629
+ await db.execute(
630
+ `
631
+ INSERT INTO junior_scheduler_tasks (
632
+ id,
633
+ team_id,
634
+ status,
635
+ next_run_at_ms,
636
+ run_now_at_ms,
637
+ created_at_ms,
638
+ record
639
+ ) VALUES (
640
+ $1, $2, $3, $4, $5, $6, $7::jsonb
641
+ )
642
+ ON CONFLICT (id) DO UPDATE SET
643
+ team_id = EXCLUDED.team_id,
644
+ status = EXCLUDED.status,
645
+ next_run_at_ms = EXCLUDED.next_run_at_ms,
646
+ run_now_at_ms = EXCLUDED.run_now_at_ms,
647
+ created_at_ms = EXCLUDED.created_at_ms,
648
+ record = EXCLUDED.record
649
+ `,
650
+ [
651
+ task.id,
652
+ task.destination.teamId,
653
+ task.status,
654
+ task.nextRunAtMs ?? null,
655
+ task.runNowAtMs ?? null,
656
+ task.createdAtMs,
657
+ json(task)
658
+ ]
659
+ );
660
+ }
661
+ async function upsertSqlRun(db, run) {
662
+ await db.execute(
663
+ `
664
+ INSERT INTO junior_scheduler_runs (
665
+ id,
666
+ task_id,
667
+ status,
668
+ scheduled_for_ms,
669
+ record
670
+ ) VALUES (
671
+ $1, $2, $3, $4, $5::jsonb
672
+ )
673
+ ON CONFLICT (id) DO UPDATE SET
674
+ task_id = EXCLUDED.task_id,
675
+ status = EXCLUDED.status,
676
+ scheduled_for_ms = EXCLUDED.scheduled_for_ms,
677
+ record = EXCLUDED.record
678
+ `,
679
+ [run.id, run.taskId, run.status, run.scheduledForMs, json(run)]
680
+ );
681
+ }
682
+ async function getTaskFromSql(db, taskId) {
683
+ const rows = await db.query(
684
+ "SELECT record FROM junior_scheduler_tasks WHERE id = $1",
685
+ [taskId]
686
+ );
687
+ return rows[0] ? parseSqlTaskRow(rows[0]) : void 0;
688
+ }
689
+ async function getRunFromSql(db, runId) {
690
+ const rows = await db.query(
691
+ "SELECT record FROM junior_scheduler_runs WHERE id = $1",
692
+ [runId]
693
+ );
694
+ return rows[0] ? parseSqlRunRow(rows[0]) : void 0;
695
+ }
696
+ async function getClaimedRunSlotFromSql(db, runId) {
697
+ const rows = await db.query(
698
+ "SELECT record FROM junior_scheduler_runs WHERE id = $1",
699
+ [runId]
700
+ );
701
+ return rows[0] ? parseSqlRunRow(rows[0]) : void 0;
702
+ }
703
+ async function listTasksFromSql(db) {
704
+ const rows = await db.query(
705
+ `
706
+ SELECT record
707
+ FROM junior_scheduler_tasks
708
+ WHERE status <> 'deleted'
709
+ ORDER BY created_at_ms ASC, id ASC
710
+ `
711
+ );
712
+ return rows.map(parseSqlTaskRow).filter(present);
713
+ }
714
+ async function listTasksForTeamFromSql(db, teamId) {
715
+ const rows = await db.query(
716
+ `
717
+ SELECT record
718
+ FROM junior_scheduler_tasks
719
+ WHERE team_id = $1
720
+ AND status <> 'deleted'
721
+ ORDER BY created_at_ms ASC, id ASC
722
+ `,
723
+ [teamId]
724
+ );
725
+ return rows.map(parseSqlTaskRow).filter(present);
726
+ }
727
+ async function listIncompleteRunsForTasksFromSql(db, tasks) {
728
+ if (tasks.length === 0) {
729
+ return [];
730
+ }
731
+ const rows = await db.query(
732
+ `
733
+ SELECT record
734
+ FROM junior_scheduler_runs
735
+ WHERE task_id = ANY($1)
736
+ AND status = ANY($2)
737
+ ORDER BY scheduled_for_ms ASC, id ASC
738
+ `,
739
+ [tasks.map((task) => task.id), [...SQL_INCOMPLETE_RUN_STATUSES]]
740
+ );
741
+ return rows.map(parseSqlRunRow).filter(present);
742
+ }
743
+ var SqlSchedulerStore = class {
744
+ constructor(db) {
745
+ this.db = db;
727
746
  }
747
+ db;
728
748
  async saveTask(task) {
729
749
  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);
750
+ await withSqlLock(this.db, taskLockKey(task.id), async (db) => {
751
+ const current = await getTaskFromSql(db, task.id);
752
+ await this.saveTaskRecord(db, next, current);
733
753
  });
734
754
  }
735
- async saveTaskRecord(task, current) {
755
+ async saveTaskRecord(db, task, current) {
736
756
  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
757
+ await db.execute(
758
+ "DELETE FROM junior_scheduler_runs WHERE id = $1 AND status = 'blocked'",
759
+ [buildRunId(task.id, task.nextRunAtMs)]
767
760
  );
768
761
  }
762
+ await upsertSqlTask(db, task);
769
763
  }
770
764
  async getTask(taskId) {
771
- return await getTaskFromState(this.state, taskId);
765
+ return await getTaskFromSql(this.db, taskId);
772
766
  }
773
767
  async listTasks() {
774
- return await listTasksFromState(this.state, globalTaskIndexKey());
768
+ return await listTasksFromSql(this.db);
775
769
  }
776
770
  async listTasksForTeam(teamId) {
777
- return await listTasksFromState(this.state, teamTaskIndexKey(teamId));
771
+ return await listTasksForTeamFromSql(this.db, teamId);
778
772
  }
779
773
  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
774
+ return await withSqlLock(this.db, "junior:scheduler:claim", async (db) => {
775
+ const rows = await db.query(
776
+ `
777
+ SELECT record
778
+ FROM junior_scheduler_tasks
779
+ WHERE status = 'active'
780
+ AND (
781
+ (run_now_at_ms IS NOT NULL AND run_now_at_ms <= $1)
782
+ OR (next_run_at_ms IS NOT NULL AND next_run_at_ms <= $1)
783
+ )
784
+ ORDER BY created_at_ms ASC, id ASC
785
+ `,
786
+ [args.nowMs]
795
787
  );
796
- let activeClaimed = await tryClaimActiveRun();
797
- if (!activeClaimed) {
798
- if (await clearStaleActiveRun(this.state, task.id, args.nowMs)) {
799
- activeClaimed = await tryClaimActiveRun();
788
+ for (const row of rows) {
789
+ const task = parseSqlTaskRow(row);
790
+ if (!task) {
791
+ continue;
800
792
  }
801
- if (!activeClaimed) {
793
+ const scheduledForMs = getDueRunAtMs(task, args.nowMs);
794
+ if (scheduledForMs === void 0) {
802
795
  continue;
803
796
  }
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;
797
+ const runId = buildRunId(task.id, scheduledForMs);
798
+ const incompleteRuns = await listIncompleteRunsForTasksFromSql(db, [
799
+ task
800
+ ]);
801
+ const incompleteRun = incompleteRuns.find((run2) => run2.id === runId);
802
+ const blockingRun = incompleteRuns.find(
803
+ (run2) => run2.id !== runId && !isStalePendingRun(run2, args.nowMs)
804
+ );
805
+ if (blockingRun) {
806
+ continue;
807
+ }
808
+ if (incompleteRun) {
809
+ if (!isStalePendingRun(incompleteRun, args.nowMs)) {
810
+ continue;
811
+ }
812
+ const reclaimed = {
813
+ ...incompleteRun,
814
+ attempt: incompleteRun.attempt + 1,
815
+ claimedAtMs: args.nowMs
816
+ };
817
+ await upsertSqlRun(db, reclaimed);
818
+ return reclaimed;
823
819
  }
824
- if (!claimed) {
825
- await clearActiveRun(this.state, task.id, runId);
820
+ const existingRun = await getClaimedRunSlotFromSql(db, runId);
821
+ if (existingRun) {
826
822
  continue;
827
823
  }
824
+ if (isMissedRunTooOld({ nowMs: args.nowMs, scheduledForMs })) {
825
+ await this.skipMissedRun(db, {
826
+ nowMs: args.nowMs,
827
+ scheduledForMs,
828
+ task
829
+ });
830
+ continue;
831
+ }
832
+ const run = buildScheduledRun({
833
+ claimedAtMs: args.nowMs,
834
+ scheduledForMs,
835
+ task
836
+ });
837
+ await upsertSqlRun(db, run);
838
+ return run;
828
839
  }
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
- );
840
+ return void 0;
875
841
  });
876
842
  }
877
- async findStaleRecoveryCanonicalTask(task) {
878
- const fingerprint = taskDedupeFingerprint(task);
879
- const ids = await getIndex(
880
- this.state,
881
- teamTaskIndexKey(task.destination.teamId)
843
+ async skipMissedRun(db, args) {
844
+ const current = await getTaskFromSql(db, args.task.id);
845
+ if (!current || current.status !== "active" || getDueRunAtMs(current, args.nowMs) !== args.scheduledForMs) {
846
+ return;
847
+ }
848
+ const duplicateOf = await this.findStaleRecoveryCanonicalTask(db, current);
849
+ 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.";
850
+ await upsertSqlRun(
851
+ db,
852
+ buildSkippedScheduledRun({
853
+ completedAtMs: args.nowMs,
854
+ errorMessage,
855
+ scheduledForMs: args.scheduledForMs,
856
+ task: current
857
+ })
882
858
  );
883
- const tasks = await Promise.all(
884
- ids.filter((id) => id !== task.id).map((id) => this.getTask(id))
859
+ const isRunNow = current.runNowAtMs === args.scheduledForMs;
860
+ let nextRunAtMs;
861
+ if (!duplicateOf) {
862
+ nextRunAtMs = isRunNow && current.nextRunAtMs !== args.scheduledForMs ? current.nextRunAtMs : current.schedule.kind === "recurring" ? getNextRunAtMs(current, args.scheduledForMs, args.nowMs) : void 0;
863
+ }
864
+ const nextStatus = nextRunAtMs ? "active" : "paused";
865
+ await this.saveTaskRecord(
866
+ db,
867
+ {
868
+ ...current,
869
+ nextRunAtMs,
870
+ runNowAtMs: isRunNow ? void 0 : current.runNowAtMs,
871
+ status: nextStatus,
872
+ statusReason: nextStatus === "paused" ? errorMessage : void 0,
873
+ updatedAtMs: args.nowMs
874
+ },
875
+ current
885
876
  );
886
- return tasks.filter((candidate) => Boolean(candidate)).filter(
877
+ }
878
+ async findStaleRecoveryCanonicalTask(db, task) {
879
+ const fingerprint = taskDedupeFingerprint(task);
880
+ const tasks = await listTasksForTeamFromSql(db, task.destination.teamId);
881
+ return tasks.filter((candidate) => candidate.id !== task.id).filter(
887
882
  (candidate) => candidate.status === "active" && isEarlierTask(candidate, task) && taskDedupeFingerprint(candidate) === fingerprint
888
883
  ).sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id)).at(0);
889
884
  }
890
885
  async getRun(runId) {
891
- return await getRunFromState(this.state, runId);
886
+ return await getRunFromSql(this.db, runId);
892
887
  }
893
888
  async listIncompleteRuns() {
894
- const tasks = await this.listTasks();
895
- return await listIncompleteRunsForTasksFromState(this.state, tasks);
889
+ return await listIncompleteRunsForTasksFromSql(
890
+ this.db,
891
+ await this.listTasks()
892
+ );
893
+ }
894
+ async listIncompleteRunsForTasks(tasks) {
895
+ return await listIncompleteRunsForTasksFromSql(this.db, tasks);
896
896
  }
897
897
  async markRunDispatched(args) {
898
898
  return await this.updateRun(
@@ -915,13 +915,10 @@ var PluginStateSchedulerStore = class {
915
915
  status: "completed"
916
916
  } : void 0
917
917
  );
918
- if (next) {
919
- await clearActiveRun(this.state, next.taskId, next.id);
920
- }
921
918
  return next;
922
919
  }
923
920
  async markRunFailed(args) {
924
- const next = await this.updateRun(
921
+ return await this.updateRun(
925
922
  args.runId,
926
923
  (run) => canFinishRun(run, args.startedAtMs) ? {
927
924
  ...run,
@@ -930,13 +927,9 @@ var PluginStateSchedulerStore = class {
930
927
  status: "failed"
931
928
  } : void 0
932
929
  );
933
- if (next) {
934
- await clearActiveRun(this.state, next.taskId, next.id);
935
- }
936
- return next;
937
930
  }
938
931
  async markRunSkipped(args) {
939
- const next = await this.updateRun(
932
+ return await this.updateRun(
940
933
  args.runId,
941
934
  (run) => run.status === "pending" ? {
942
935
  ...run,
@@ -945,13 +938,9 @@ var PluginStateSchedulerStore = class {
945
938
  status: "skipped"
946
939
  } : void 0
947
940
  );
948
- if (next) {
949
- await clearActiveRun(this.state, next.taskId, next.id);
950
- }
951
- return next;
952
941
  }
953
942
  async markRunBlocked(args) {
954
- const next = await this.updateRun(
943
+ return await this.updateRun(
955
944
  args.runId,
956
945
  (run) => canFinishRun(run, args.startedAtMs) ? {
957
946
  ...run,
@@ -960,14 +949,10 @@ var PluginStateSchedulerStore = class {
960
949
  status: "blocked"
961
950
  } : void 0
962
951
  );
963
- if (next) {
964
- await clearActiveRun(this.state, next.taskId, next.id);
965
- }
966
- return next;
967
952
  }
968
953
  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;
954
+ await withSqlLock(this.db, taskLockKey(args.run.taskId), async (db) => {
955
+ const current = await getTaskFromSql(db, args.run.taskId);
971
956
  if (!current || current.status === "deleted") {
972
957
  return;
973
958
  }
@@ -982,6 +967,7 @@ var PluginStateSchedulerStore = class {
982
967
  );
983
968
  }
984
969
  await this.saveTaskRecord(
970
+ db,
985
971
  {
986
972
  ...current,
987
973
  lastRunAtMs: args.run.scheduledForMs,
@@ -989,8 +975,7 @@ var PluginStateSchedulerStore = class {
989
975
  runNowAtMs: void 0,
990
976
  status: args.status === "blocked" ? "blocked" : nextRunAtMs2 ? current.status : "paused",
991
977
  statusReason: args.status === "blocked" ? args.errorMessage : void 0,
992
- updatedAtMs: args.nowMs,
993
- version: current.version + 1
978
+ updatedAtMs: args.nowMs
994
979
  },
995
980
  current
996
981
  );
@@ -998,11 +983,11 @@ var PluginStateSchedulerStore = class {
998
983
  }
999
984
  if (current.status !== "active" || current.nextRunAtMs !== args.run.scheduledForMs) {
1000
985
  await this.saveTaskRecord(
986
+ db,
1001
987
  {
1002
988
  ...current,
1003
989
  lastRunAtMs: args.run.scheduledForMs,
1004
- updatedAtMs: args.nowMs,
1005
- version: current.version + 1
990
+ updatedAtMs: args.nowMs
1006
991
  },
1007
992
  current
1008
993
  );
@@ -1010,55 +995,151 @@ var PluginStateSchedulerStore = class {
1010
995
  }
1011
996
  const nextRunAtMs = args.status === "blocked" ? void 0 : getNextRunAtMs(current, args.run.scheduledForMs, args.nowMs);
1012
997
  await this.saveTaskRecord(
998
+ db,
1013
999
  {
1014
1000
  ...current,
1015
1001
  lastRunAtMs: args.run.scheduledForMs,
1016
1002
  nextRunAtMs,
1017
1003
  status: args.status === "blocked" ? "blocked" : nextRunAtMs ? "active" : "paused",
1018
1004
  statusReason: args.status === "blocked" ? args.errorMessage : void 0,
1019
- updatedAtMs: args.nowMs,
1020
- version: current.version + 1
1005
+ updatedAtMs: args.nowMs
1021
1006
  },
1022
1007
  current
1023
1008
  );
1024
1009
  });
1025
1010
  }
1026
1011
  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;
1012
+ return await withSqlLock(
1013
+ this.db,
1014
+ indexLockKey(runKey(runId)),
1015
+ async (db) => {
1016
+ const current = await getRunFromSql(db, runId);
1017
+ if (!current) {
1018
+ return void 0;
1019
+ }
1020
+ const next = update(current);
1021
+ if (!next) {
1022
+ return void 0;
1023
+ }
1024
+ await upsertSqlRun(db, next);
1025
+ return next;
1035
1026
  }
1036
- await this.state.set(runKey(runId), next, SCHEDULED_RUN_TTL_MS);
1037
- return next;
1038
- });
1027
+ );
1039
1028
  }
1040
1029
  };
1041
- function createSchedulerStore(state) {
1042
- return new PluginStateSchedulerStore(state);
1030
+ function createSchedulerSqlStore(db) {
1031
+ return new SqlSchedulerStore(db);
1032
+ }
1033
+ function createSchedulerOperationalSqlStore(db) {
1034
+ return new SqlSchedulerStore(db);
1035
+ }
1036
+ async function migrateSchedulerStateToSql(args) {
1037
+ const store = createSchedulerSqlStore(args.db);
1038
+ const ids = await getIndex(args.state, globalTaskIndexKey());
1039
+ let existing = 0;
1040
+ let migrated = 0;
1041
+ let missing = 0;
1042
+ const migratedTasks = [];
1043
+ for (const id of ids) {
1044
+ const task = await getTaskFromState(args.state, id);
1045
+ if (!task) {
1046
+ missing += 1;
1047
+ continue;
1048
+ }
1049
+ migratedTasks.push(task);
1050
+ if (await store.getTask(task.id)) {
1051
+ existing += 1;
1052
+ continue;
1053
+ }
1054
+ await store.saveTask(task);
1055
+ migrated += 1;
1056
+ }
1057
+ const runs = await listIncompleteRunsForTasksFromState(
1058
+ args.state,
1059
+ migratedTasks
1060
+ );
1061
+ for (const run of runs) {
1062
+ if (await store.getRun(run.id)) {
1063
+ existing += 1;
1064
+ continue;
1065
+ }
1066
+ await upsertSqlRun(args.db, run);
1067
+ migrated += 1;
1068
+ }
1069
+ return {
1070
+ existing,
1071
+ migrated,
1072
+ missing,
1073
+ scanned: ids.length + runs.length
1074
+ };
1075
+ }
1076
+
1077
+ // src/identity.ts
1078
+ var SLACK_USER_ID_PATTERN = /^[UW][A-Z0-9]{5,}$/;
1079
+ function clean(value) {
1080
+ const normalized = value?.trim();
1081
+ return normalized ? normalized : void 0;
1082
+ }
1083
+ function cleanSlackUserId(value) {
1084
+ const normalized = clean(value);
1085
+ return normalized && normalized.toLowerCase() !== "unknown" ? normalized : void 0;
1086
+ }
1087
+ function cleanDisplay(value, slackUserId) {
1088
+ const normalized = clean(value);
1089
+ if (!normalized || normalized.toLowerCase() === "unknown" || normalized === slackUserId) {
1090
+ return void 0;
1091
+ }
1092
+ return SLACK_USER_ID_PATTERN.test(normalized) ? void 0 : normalized;
1093
+ }
1094
+ function sanitizeScheduledTaskPrincipal(principal) {
1095
+ const slackUserId = cleanSlackUserId(principal.slackUserId);
1096
+ if (!slackUserId) {
1097
+ throw new Error("Scheduled task principal requires a Slack user id");
1098
+ }
1099
+ const fullName = cleanDisplay(principal.fullName, slackUserId);
1100
+ const userName = cleanDisplay(principal.userName, slackUserId);
1101
+ return {
1102
+ slackUserId,
1103
+ ...fullName ? { fullName } : {},
1104
+ ...userName ? { userName } : {}
1105
+ };
1043
1106
  }
1044
- function createSchedulerOperationalStore(state) {
1045
- return new PluginStateSchedulerOperationalStore(state);
1107
+ function scheduledTaskPrincipalLabel(principal) {
1108
+ const author = sanitizeScheduledTaskPrincipal(principal);
1109
+ if (author.fullName && author.userName) {
1110
+ return `${author.fullName} (@${author.userName})`;
1111
+ }
1112
+ if (author.fullName) {
1113
+ return author.fullName;
1114
+ }
1115
+ if (author.userName) {
1116
+ return `@${author.userName}`;
1117
+ }
1118
+ return `Slack User ${author.slackUserId}`;
1046
1119
  }
1047
1120
 
1048
1121
  // src/schedule-tools.ts
1049
1122
  import { randomUUID } from "crypto";
1050
1123
  import { Type } from "@sinclair/typebox";
1051
1124
  import {
1052
- AgentPluginToolInputError,
1053
- agentPluginCredentialSubjectSchema as agentPluginCredentialSubjectSchema2,
1125
+ PluginToolInputError,
1126
+ pluginCredentialSubjectSchema as pluginCredentialSubjectSchema2,
1054
1127
  destinationSchema as destinationSchema2,
1055
1128
  isSlackDestination as isSlackDestination2
1056
1129
  } from "@sentry/junior-plugin-api";
1130
+
1131
+ // src/types.ts
1132
+ var SCHEDULED_TASK_SYSTEM_ACTOR = Object.freeze({
1133
+ type: "system",
1134
+ id: "scheduled-task"
1135
+ });
1136
+
1137
+ // src/schedule-tools.ts
1057
1138
  var TASK_ID_PREFIX = "sched";
1058
1139
  var MAX_LISTED_TASKS = 50;
1059
1140
  var DEFAULT_SCHEDULE_TIMEZONE = "America/Los_Angeles";
1060
1141
  function throwToolInputError(error) {
1061
- throw new AgentPluginToolInputError(error);
1142
+ throw new PluginToolInputError(error);
1062
1143
  }
1063
1144
  function requireActiveConversation(context) {
1064
1145
  const parsed = destinationSchema2.safeParse(context.source);
@@ -1125,7 +1206,7 @@ function getCredentialSubject(args) {
1125
1206
  if (!args.subject) {
1126
1207
  return void 0;
1127
1208
  }
1128
- const subject = agentPluginCredentialSubjectSchema2.safeParse(args.subject);
1209
+ const subject = pluginCredentialSubjectSchema2.safeParse(args.subject);
1129
1210
  if (!subject.success) {
1130
1211
  throwToolInputError("Active Slack credential subject is invalid.");
1131
1212
  }
@@ -1141,9 +1222,7 @@ function sameDestination(task, destination) {
1141
1222
  }
1142
1223
  async function getWritableTask(args) {
1143
1224
  const destination = requireActiveConversation(args.context);
1144
- const task = await createSchedulerStore(args.context.state).getTask(
1145
- args.taskId
1146
- );
1225
+ const task = await schedulerStore(args.context).getTask(args.taskId);
1147
1226
  if (!task || task.status === "deleted") {
1148
1227
  throwToolInputError(
1149
1228
  "Scheduled task was not found in the active Slack conversation."
@@ -1179,13 +1258,15 @@ function compactTask(task) {
1179
1258
  allowed_when: task.credentialSubject.allowedWhen
1180
1259
  } : null,
1181
1260
  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
1261
+ run_now_at: task.runNowAtMs ? new Date(task.runNowAtMs).toISOString() : null
1184
1262
  };
1185
1263
  }
1186
1264
  function buildTaskId() {
1187
1265
  return `${TASK_ID_PREFIX}_${randomUUID()}`;
1188
1266
  }
1267
+ function schedulerStore(context) {
1268
+ return context.store;
1269
+ }
1189
1270
  function normalizeStatus(value) {
1190
1271
  if (value === "active" || value === "paused" || value === "blocked") {
1191
1272
  return value;
@@ -1230,6 +1311,20 @@ function validateRecurringFrequencyLimit(input) {
1230
1311
  );
1231
1312
  }
1232
1313
  }
1314
+ function validateCreateScheduleKind(input) {
1315
+ if (input.schedule_kind === void 0) {
1316
+ throwToolInputError("Provide schedule_kind as one_off or recurring.");
1317
+ }
1318
+ if (input.schedule_kind !== "one_off" && input.schedule_kind !== "recurring") {
1319
+ throwToolInputError("schedule_kind must be one_off or recurring.");
1320
+ }
1321
+ if (input.schedule_kind === "one_off" && input.recurrence !== void 0) {
1322
+ throwToolInputError("Omit recurrence when schedule_kind is one_off.");
1323
+ }
1324
+ if (input.schedule_kind === "recurring" && (input.recurrence === void 0 || input.recurrence === null)) {
1325
+ throwToolInputError("Provide recurrence when schedule_kind is recurring.");
1326
+ }
1327
+ }
1233
1328
  function shouldRebuildRecurrence(input) {
1234
1329
  return input.next_run_at !== void 0 || input.recurrence !== void 0 || input.timezone !== void 0;
1235
1330
  }
@@ -1256,11 +1351,17 @@ function parseNextRunAtMs(nextRunAtIso) {
1256
1351
  }
1257
1352
  function createSlackScheduleCreateTaskTool(context) {
1258
1353
  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.",
1354
+ 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
1355
  executionMode: "sequential",
1261
1356
  inputSchema: Type.Object({
1262
1357
  task: Type.String({ minLength: 1, maxLength: 4e3 }),
1263
1358
  schedule: Type.String({ minLength: 1, maxLength: 300 }),
1359
+ schedule_kind: Type.Union(
1360
+ [Type.Literal("one_off"), Type.Literal("recurring")],
1361
+ {
1362
+ 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."
1363
+ }
1364
+ ),
1264
1365
  timezone: Type.Optional(
1265
1366
  Type.String({
1266
1367
  minLength: 1,
@@ -1283,7 +1384,7 @@ function createSlackScheduleCreateTaskTool(context) {
1283
1384
  Type.Literal("yearly")
1284
1385
  ],
1285
1386
  {
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."
1387
+ 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
1388
  }
1288
1389
  )
1289
1390
  )
@@ -1293,6 +1394,7 @@ function createSlackScheduleCreateTaskTool(context) {
1293
1394
  const requester = requireRequester(context);
1294
1395
  const nowMs = Date.now();
1295
1396
  const timezone = input.timezone ?? getDefaultScheduleTimezone();
1397
+ validateCreateScheduleKind(input);
1296
1398
  validateRecurringFrequencyLimit(input);
1297
1399
  if (!isValidTimeZone(timezone)) {
1298
1400
  throwToolInputError("timezone must be a valid IANA time zone.");
@@ -1331,10 +1433,9 @@ function createSlackScheduleCreateTaskTool(context) {
1331
1433
  status: "active",
1332
1434
  task: {
1333
1435
  text: input.task
1334
- },
1335
- version: 1
1436
+ }
1336
1437
  };
1337
- await createSchedulerStore(context.state).saveTask(task);
1438
+ await schedulerStore(context).saveTask(task);
1338
1439
  return {
1339
1440
  ok: true,
1340
1441
  task: compactTask(task)
@@ -1349,7 +1450,7 @@ function createSlackScheduleListTasksTool(context) {
1349
1450
  inputSchema: Type.Object({}),
1350
1451
  execute: async () => {
1351
1452
  const destination = requireActiveConversation(context);
1352
- const tasks = await createSchedulerStore(context.state).listTasksForTeam(
1453
+ const tasks = await schedulerStore(context).listTasksForTeam(
1353
1454
  destination.teamId
1354
1455
  );
1355
1456
  const matching = tasks.filter(
@@ -1454,10 +1555,9 @@ function createSlackScheduleUpdateTaskTool(context) {
1454
1555
  kind: recurrence ? "recurring" : "one_off",
1455
1556
  recurrence
1456
1557
  },
1457
- task: input.task ? { text: input.task } : lookup.task,
1458
- version: lookup.version + 1
1558
+ task: input.task ? { text: input.task } : lookup.task
1459
1559
  };
1460
- await createSchedulerStore(context.state).saveTask(next);
1560
+ await schedulerStore(context).saveTask(next);
1461
1561
  return {
1462
1562
  ok: true,
1463
1563
  task: compactTask(next)
@@ -1482,10 +1582,9 @@ function createSlackScheduleDeleteTaskTool(context) {
1482
1582
  updatedAtMs: Date.now(),
1483
1583
  status: "deleted",
1484
1584
  nextRunAtMs: void 0,
1485
- runNowAtMs: void 0,
1486
- version: lookup.version + 1
1585
+ runNowAtMs: void 0
1487
1586
  };
1488
- await createSchedulerStore(context.state).saveTask(next);
1587
+ await schedulerStore(context).saveTask(next);
1489
1588
  return {
1490
1589
  ok: true,
1491
1590
  task: compactTask(next)
@@ -1514,10 +1613,9 @@ function createSlackScheduleRunTaskNowTool(context) {
1514
1613
  const next = {
1515
1614
  ...lookup,
1516
1615
  updatedAtMs: nowMs,
1517
- runNowAtMs: nowMs,
1518
- version: lookup.version + 1
1616
+ runNowAtMs: nowMs
1519
1617
  };
1520
- await createSchedulerStore(context.state).saveTask(next);
1618
+ await schedulerStore(context).saveTask(next);
1521
1619
  return {
1522
1620
  ok: true,
1523
1621
  task: compactTask(next)
@@ -1529,6 +1627,50 @@ function createSlackScheduleRunTaskNowTool(context) {
1529
1627
  // src/plugin.ts
1530
1628
  var SCHEDULER_HEARTBEAT_LIMIT = 10;
1531
1629
  var DASHBOARD_TABLE_LIMIT = 5;
1630
+ function singleLineMetadataValue(value) {
1631
+ return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
1632
+ }
1633
+ function buildScheduledTaskDispatchMetadata(args) {
1634
+ const { run, task } = args;
1635
+ const creator = sanitizeScheduledTaskPrincipal(task.createdBy);
1636
+ if (!task.task.text?.trim()) {
1637
+ throw new Error("Scheduled task text is required");
1638
+ }
1639
+ return {
1640
+ creatorSlackUserId: creator.slackUserId,
1641
+ runId: run.id,
1642
+ schedule: singleLineMetadataValue(task.schedule.description),
1643
+ scheduleKind: task.schedule.kind,
1644
+ scheduledFor: new Date(run.scheduledForMs).toISOString(),
1645
+ runningAt: new Date(args.nowMs).toISOString(),
1646
+ taskId: task.id,
1647
+ timezone: task.schedule.timezone,
1648
+ ...task.schedule.recurrence ? {
1649
+ recurrenceFrequency: task.schedule.recurrence.frequency,
1650
+ recurrenceInterval: String(task.schedule.recurrence.interval),
1651
+ recurrenceStartDate: task.schedule.recurrence.startDate
1652
+ } : {}
1653
+ };
1654
+ }
1655
+ function scheduledTaskDispatchSource(task) {
1656
+ return {
1657
+ platform: "slack",
1658
+ teamId: task.destination.teamId,
1659
+ channelId: task.destination.channelId
1660
+ };
1661
+ }
1662
+ function schedulerStore2(ctx) {
1663
+ if (!ctx.db) {
1664
+ throw new Error("Scheduler plugin requires ctx.db");
1665
+ }
1666
+ return createSchedulerSqlStore(ctx.db);
1667
+ }
1668
+ function schedulerOperationalStore(ctx) {
1669
+ if (!ctx.db) {
1670
+ throw new Error("Scheduler plugin requires ctx.db");
1671
+ }
1672
+ return createSchedulerOperationalSqlStore(ctx.db);
1673
+ }
1532
1674
  function shouldSkipRun(task, run) {
1533
1675
  if (task.status === "deleted") {
1534
1676
  return `Scheduled task ${task.id} was deleted before the run started.`;
@@ -1550,7 +1692,7 @@ function createSchedulerToolContext(ctx) {
1550
1692
  channelId: ctx.source.channelId
1551
1693
  } : void 0,
1552
1694
  requester: ctx.requester?.platform === "slack" ? ctx.requester : void 0,
1553
- state: ctx.state,
1695
+ store: schedulerStore2(ctx),
1554
1696
  userText: ctx.userText
1555
1697
  };
1556
1698
  }
@@ -1795,12 +1937,13 @@ async function buildSchedulerOperationalReport(args) {
1795
1937
  }
1796
1938
  function createSchedulerPlugin() {
1797
1939
  return defineJuniorPlugin({
1940
+ database: {},
1798
1941
  manifest: {
1799
1942
  name: "scheduler",
1800
1943
  displayName: "Scheduler",
1801
1944
  description: "Scheduled Junior task management and heartbeat dispatch"
1802
1945
  },
1803
- legacyStatePrefixes: ["junior:scheduler"],
1946
+ packageName: "@sentry/junior-scheduler",
1804
1947
  hooks: {
1805
1948
  tools(ctx) {
1806
1949
  if (ctx.source.platform !== "slack" || ctx.requester?.platform !== "slack") {
@@ -1816,7 +1959,7 @@ function createSchedulerPlugin() {
1816
1959
  };
1817
1960
  },
1818
1961
  async heartbeat(ctx) {
1819
- const store = createSchedulerStore(ctx.state);
1962
+ const store = schedulerStore2(ctx);
1820
1963
  let processedCount = 0;
1821
1964
  let dispatchCount = 0;
1822
1965
  for (const run of await store.listIncompleteRuns()) {
@@ -1865,15 +2008,15 @@ function createSchedulerPlugin() {
1865
2008
  });
1866
2009
  continue;
1867
2010
  }
1868
- let prompt;
2011
+ let dispatchMetadata;
1869
2012
  try {
1870
- prompt = buildScheduledTaskRunPrompt({
2013
+ dispatchMetadata = buildScheduledTaskDispatchMetadata({
1871
2014
  nowMs: ctx.nowMs,
1872
2015
  run,
1873
2016
  task
1874
2017
  });
1875
2018
  } catch (error) {
1876
- const errorMessage = error instanceof Error ? `Scheduled task prompt could not be built: ${error.message}` : "Scheduled task prompt could not be built.";
2019
+ const errorMessage = error instanceof Error ? `Scheduled task dispatch metadata could not be built: ${error.message}` : "Scheduled task dispatch metadata could not be built.";
1877
2020
  await blockClaimedRun({
1878
2021
  errorMessage,
1879
2022
  nowMs: ctx.nowMs,
@@ -1888,11 +2031,9 @@ function createSchedulerPlugin() {
1888
2031
  idempotencyKey: run.id,
1889
2032
  ...task.credentialSubject ? { credentialSubject: task.credentialSubject } : {},
1890
2033
  destination: task.destination,
1891
- input: prompt,
1892
- metadata: {
1893
- runId: run.id,
1894
- taskId: task.id
1895
- }
2034
+ input: task.task.text,
2035
+ metadata: dispatchMetadata,
2036
+ source: scheduledTaskDispatchSource(task)
1896
2037
  });
1897
2038
  } catch (error) {
1898
2039
  const errorMessage = error instanceof Error ? `Scheduled task dispatch could not be created: ${error.message}` : "Scheduled task dispatch could not be created.";
@@ -1917,7 +2058,16 @@ function createSchedulerPlugin() {
1917
2058
  async operationalReport(ctx) {
1918
2059
  return buildSchedulerOperationalReport({
1919
2060
  nowMs: ctx.nowMs,
1920
- store: createSchedulerOperationalStore(ctx.state)
2061
+ store: schedulerOperationalStore(ctx)
2062
+ });
2063
+ },
2064
+ async migrateStorage(ctx) {
2065
+ if (!ctx.db) {
2066
+ throw new Error("Scheduler storage migration requires ctx.db");
2067
+ }
2068
+ return await migrateSchedulerStateToSql({
2069
+ db: ctx.db,
2070
+ state: ctx.state
1921
2071
  });
1922
2072
  }
1923
2073
  }
@@ -1925,13 +2075,14 @@ function createSchedulerPlugin() {
1925
2075
  }
1926
2076
  var schedulerPlugin = createSchedulerPlugin;
1927
2077
  export {
1928
- buildScheduledTaskRunPrompt,
2078
+ createSchedulerOperationalSqlStore,
1929
2079
  createSchedulerPlugin,
1930
- createSchedulerStore,
2080
+ createSchedulerSqlStore,
1931
2081
  createSlackScheduleCreateTaskTool,
1932
2082
  createSlackScheduleDeleteTaskTool,
1933
2083
  createSlackScheduleListTasksTool,
1934
2084
  createSlackScheduleRunTaskNowTool,
1935
2085
  createSlackScheduleUpdateTaskTool,
2086
+ migrateSchedulerStateToSql,
1936
2087
  schedulerPlugin
1937
2088
  };