@sentry/junior-scheduler 0.66.3 → 0.67.1

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.
@@ -0,0 +1,5 @@
1
+ import type { ScheduledTaskPrincipal } from "./types";
2
+ /** Keep scheduler creator metadata from promoting Slack IDs into display names. */
3
+ export declare function sanitizeScheduledTaskPrincipal(principal: ScheduledTaskPrincipal): ScheduledTaskPrincipal;
4
+ /** Render scheduler creator metadata without inventing human profile fields. */
5
+ export declare function scheduledTaskPrincipalLabel(principal: ScheduledTaskPrincipal): string;
package/dist/index.js CHANGED
@@ -9,6 +9,50 @@ var SCHEDULED_TASK_SYSTEM_ACTOR = Object.freeze({
9
9
  id: "scheduled-task"
10
10
  });
11
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
+
12
56
  // src/prompt.ts
13
57
  function escapeXml(value) {
14
58
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
@@ -28,7 +72,7 @@ function renderOptionalLine(name, value) {
28
72
  function buildScheduledTaskRunPrompt(args) {
29
73
  const { run, task } = args;
30
74
  const destination = task.destination;
31
- const creator = task.createdBy;
75
+ const creator = sanitizeScheduledTaskPrincipal(task.createdBy);
32
76
  const executionActor = task.executionActor ?? SCHEDULED_TASK_SYSTEM_ACTOR;
33
77
  if (!task.task.text?.trim()) {
34
78
  throw new Error("Scheduled task text is required");
@@ -606,6 +650,43 @@ function canFinishRun(run, startedAtMs) {
606
650
  }
607
651
  return run.status === "running" && run.startedAtMs === startedAtMs;
608
652
  }
653
+ async function getTaskFromState(state, taskId) {
654
+ return await state.get(taskKey(taskId)) ?? void 0;
655
+ }
656
+ async function listTasksFromState(state, indexKey) {
657
+ const ids = await getIndex(state, indexKey);
658
+ const tasks = await Promise.all(ids.map((id) => getTaskFromState(state, id)));
659
+ return tasks.filter((task) => Boolean(task)).filter((task) => task.status !== "deleted").sort((a, b) => a.createdAtMs - b.createdAtMs);
660
+ }
661
+ async function getRunFromState(state, runId) {
662
+ return await state.get(runKey(runId)) ?? void 0;
663
+ }
664
+ async function listIncompleteRunsForTasksFromState(state, tasks) {
665
+ const runs = [];
666
+ for (const task of tasks) {
667
+ const active = await state.get(activeRunKey(task.id));
668
+ if (typeof active?.runId !== "string") {
669
+ continue;
670
+ }
671
+ const run = await getRunFromState(state, active.runId);
672
+ if (run && !isFinishedRun(run)) {
673
+ runs.push(run);
674
+ }
675
+ }
676
+ return runs;
677
+ }
678
+ var PluginStateSchedulerOperationalStore = class {
679
+ state;
680
+ constructor(state) {
681
+ this.state = state;
682
+ }
683
+ async listTasks() {
684
+ return await listTasksFromState(this.state, globalTaskIndexKey());
685
+ }
686
+ async listIncompleteRunsForTasks(tasks) {
687
+ return await listIncompleteRunsForTasksFromState(this.state, tasks);
688
+ }
689
+ };
609
690
  var PluginStateSchedulerStore = class {
610
691
  state;
611
692
  constructor(state) {
@@ -653,12 +734,13 @@ var PluginStateSchedulerStore = class {
653
734
  }
654
735
  }
655
736
  async getTask(taskId) {
656
- return await this.state.get(taskKey(taskId)) ?? void 0;
737
+ return await getTaskFromState(this.state, taskId);
738
+ }
739
+ async listTasks() {
740
+ return await listTasksFromState(this.state, globalTaskIndexKey());
657
741
  }
658
742
  async listTasksForTeam(teamId) {
659
- const ids = await getIndex(this.state, teamTaskIndexKey(teamId));
660
- const tasks = await Promise.all(ids.map((id) => this.getTask(id)));
661
- return tasks.filter((task) => Boolean(task)).filter((task) => task.status !== "deleted").sort((a, b) => a.createdAtMs - b.createdAtMs);
743
+ return await listTasksFromState(this.state, teamTaskIndexKey(teamId));
662
744
  }
663
745
  async claimDueRun(args) {
664
746
  const ids = await getIndex(this.state, globalTaskIndexKey());
@@ -772,24 +854,11 @@ var PluginStateSchedulerStore = class {
772
854
  ).sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id)).at(0);
773
855
  }
774
856
  async getRun(runId) {
775
- return await this.state.get(runKey(runId)) ?? void 0;
857
+ return await getRunFromState(this.state, runId);
776
858
  }
777
859
  async listIncompleteRuns() {
778
- const ids = await getIndex(this.state, globalTaskIndexKey());
779
- const runs = [];
780
- for (const taskId of ids) {
781
- const active = await this.state.get(
782
- activeRunKey(taskId)
783
- );
784
- if (typeof active?.runId !== "string") {
785
- continue;
786
- }
787
- const run = await this.getRun(active.runId);
788
- if (run && !isFinishedRun(run)) {
789
- runs.push(run);
790
- }
791
- }
792
- return runs;
860
+ const tasks = await this.listTasks();
861
+ return await listIncompleteRunsForTasksFromState(this.state, tasks);
793
862
  }
794
863
  async markRunDispatched(args) {
795
864
  return await this.updateRun(
@@ -938,6 +1007,9 @@ var PluginStateSchedulerStore = class {
938
1007
  function createSchedulerStore(state) {
939
1008
  return new PluginStateSchedulerStore(state);
940
1009
  }
1010
+ function createSchedulerOperationalStore(state) {
1011
+ return new PluginStateSchedulerOperationalStore(state);
1012
+ }
941
1013
 
942
1014
  // src/schedule-tools.ts
943
1015
  import { randomUUID } from "crypto";
@@ -969,15 +1041,15 @@ function requireActiveDestination(context) {
969
1041
  };
970
1042
  }
971
1043
  function requireRequester(context) {
972
- const userId = context.requester?.userId;
973
- if (!userId) {
1044
+ const userId = context.requester?.userId?.trim();
1045
+ if (!userId || userId.toLowerCase() === "unknown") {
974
1046
  throwToolInputError("No active Slack requester context is available.");
975
1047
  }
976
- return {
1048
+ return sanitizeScheduledTaskPrincipal({
977
1049
  slackUserId: userId,
978
1050
  ...context.requester?.userName ? { userName: context.requester.userName } : {},
979
1051
  ...context.requester?.fullName ? { fullName: context.requester.fullName } : {}
980
- };
1052
+ });
981
1053
  }
982
1054
  function tool(definition) {
983
1055
  return definition;
@@ -1413,6 +1485,7 @@ function createSlackScheduleRunTaskNowTool(context) {
1413
1485
 
1414
1486
  // src/plugin.ts
1415
1487
  var SCHEDULER_HEARTBEAT_LIMIT = 10;
1488
+ var DASHBOARD_TABLE_LIMIT = 5;
1416
1489
  function shouldSkipRun(task, run) {
1417
1490
  if (task.status === "deleted") {
1418
1491
  return `Scheduled task ${task.id} was deleted before the run started.`;
@@ -1531,6 +1604,156 @@ async function failClaimedRun(args) {
1531
1604
  status: "failed"
1532
1605
  });
1533
1606
  }
1607
+ function formatCount(value) {
1608
+ return String(value);
1609
+ }
1610
+ function formatTimestamp(timestampMs) {
1611
+ return typeof timestampMs === "number" && Number.isFinite(timestampMs) ? new Date(timestampMs).toISOString() : "none";
1612
+ }
1613
+ function destinationLabel(destination) {
1614
+ if (destination.channelId.startsWith("D")) {
1615
+ return "Direct Message";
1616
+ }
1617
+ if (destination.channelId.startsWith("C")) {
1618
+ return `Public Channel ${destination.channelId}`;
1619
+ }
1620
+ if (destination.channelId.startsWith("G")) {
1621
+ return `Private Destination ${destination.channelId}`;
1622
+ }
1623
+ return destination.channelId;
1624
+ }
1625
+ function operationalAuthorLabel(author) {
1626
+ try {
1627
+ return scheduledTaskPrincipalLabel(author);
1628
+ } catch {
1629
+ return "Invalid Slack creator metadata";
1630
+ }
1631
+ }
1632
+ function cadenceLabel(task) {
1633
+ if (task.schedule.kind === "one_off") {
1634
+ return "one-off";
1635
+ }
1636
+ return task.schedule.recurrence ? task.schedule.recurrence.frequency : "recurring";
1637
+ }
1638
+ function taskStatusCounts(tasks) {
1639
+ return tasks.reduce(
1640
+ (counts, task) => ({
1641
+ active: counts.active + (task.status === "active" ? 1 : 0),
1642
+ blocked: counts.blocked + (task.status === "blocked" ? 1 : 0),
1643
+ paused: counts.paused + (task.status === "paused" ? 1 : 0)
1644
+ }),
1645
+ { active: 0, blocked: 0, paused: 0 }
1646
+ );
1647
+ }
1648
+ function isDue(task, nowMs) {
1649
+ return task.status === "active" && (typeof task.runNowAtMs === "number" && task.runNowAtMs <= nowMs || typeof task.nextRunAtMs === "number" && task.nextRunAtMs <= nowMs);
1650
+ }
1651
+ async function buildSchedulerOperationalReport(args) {
1652
+ const tasks = await args.store.listTasks();
1653
+ const incompleteRuns = await args.store.listIncompleteRunsForTasks(tasks);
1654
+ const taskById = new Map(tasks.map((task) => [task.id, task]));
1655
+ const counts = taskStatusCounts(tasks);
1656
+ const dueCount = tasks.filter((task) => isDue(task, args.nowMs)).length;
1657
+ const upcomingTasks = tasks.filter((task) => task.status === "active" && task.nextRunAtMs).sort((left, right) => left.nextRunAtMs - right.nextRunAtMs).slice(0, DASHBOARD_TABLE_LIMIT);
1658
+ const blockedTasks = tasks.filter((task) => task.status === "blocked").sort((left, right) => right.updatedAtMs - left.updatedAtMs).slice(0, DASHBOARD_TABLE_LIMIT);
1659
+ const runningCount = incompleteRuns.length;
1660
+ const runningRuns = incompleteRuns.sort((left, right) => right.claimedAtMs - left.claimedAtMs).slice(0, DASHBOARD_TABLE_LIMIT);
1661
+ return {
1662
+ title: "Scheduler",
1663
+ generatedAt: new Date(args.nowMs).toISOString(),
1664
+ metrics: [
1665
+ {
1666
+ label: "active",
1667
+ tone: counts.active > 0 ? "good" : "neutral",
1668
+ value: formatCount(counts.active)
1669
+ },
1670
+ {
1671
+ label: "blocked",
1672
+ tone: counts.blocked > 0 ? "danger" : "neutral",
1673
+ value: formatCount(counts.blocked)
1674
+ },
1675
+ { label: "paused", value: formatCount(counts.paused) },
1676
+ {
1677
+ label: "due now",
1678
+ tone: dueCount > 0 ? "warning" : "neutral",
1679
+ value: formatCount(dueCount)
1680
+ },
1681
+ {
1682
+ label: "running",
1683
+ tone: runningCount > 0 ? "warning" : "neutral",
1684
+ value: formatCount(runningCount)
1685
+ }
1686
+ ],
1687
+ recordSets: [
1688
+ {
1689
+ title: "Upcoming",
1690
+ emptyText: "No active scheduled tasks.",
1691
+ fields: [
1692
+ { key: "task", label: "Task" },
1693
+ { key: "author", label: "Author" },
1694
+ { key: "destination", label: "Destination" },
1695
+ { key: "nextRun", label: "Next Run" },
1696
+ { key: "cadence", label: "Cadence" }
1697
+ ],
1698
+ records: upcomingTasks.map((task) => ({
1699
+ id: task.id,
1700
+ values: {
1701
+ task: task.id,
1702
+ author: operationalAuthorLabel(task.createdBy),
1703
+ destination: destinationLabel(task.destination),
1704
+ nextRun: formatTimestamp(task.nextRunAtMs),
1705
+ cadence: cadenceLabel(task)
1706
+ }
1707
+ }))
1708
+ },
1709
+ {
1710
+ title: "Blocked",
1711
+ emptyText: "No blocked scheduled tasks.",
1712
+ fields: [
1713
+ { key: "task", label: "Task" },
1714
+ { key: "author", label: "Author" },
1715
+ { key: "destination", label: "Destination" },
1716
+ { key: "updated", label: "Updated" }
1717
+ ],
1718
+ records: blockedTasks.map((task) => ({
1719
+ id: task.id,
1720
+ tone: "danger",
1721
+ values: {
1722
+ task: task.id,
1723
+ author: operationalAuthorLabel(task.createdBy),
1724
+ destination: destinationLabel(task.destination),
1725
+ updated: formatTimestamp(task.updatedAtMs)
1726
+ }
1727
+ }))
1728
+ },
1729
+ {
1730
+ title: "Running",
1731
+ emptyText: "No scheduler runs in flight.",
1732
+ fields: [
1733
+ { key: "run", label: "Run" },
1734
+ { key: "task", label: "Task" },
1735
+ { key: "author", label: "Author" },
1736
+ { key: "scheduledFor", label: "Scheduled For" },
1737
+ { key: "status", label: "Status" }
1738
+ ],
1739
+ records: runningRuns.map((run) => {
1740
+ const task = taskById.get(run.taskId);
1741
+ return {
1742
+ id: run.id,
1743
+ tone: run.status === "pending" ? "warning" : "neutral",
1744
+ values: {
1745
+ run: run.id,
1746
+ task: run.taskId,
1747
+ author: task ? operationalAuthorLabel(task.createdBy) : "Missing scheduled task",
1748
+ scheduledFor: formatTimestamp(run.scheduledForMs),
1749
+ status: run.status
1750
+ }
1751
+ };
1752
+ })
1753
+ }
1754
+ ]
1755
+ };
1756
+ }
1534
1757
  function createSchedulerPlugin() {
1535
1758
  return defineJuniorPlugin({
1536
1759
  manifest: {
@@ -1650,6 +1873,12 @@ function createSchedulerPlugin() {
1650
1873
  dispatchCount += 1;
1651
1874
  }
1652
1875
  return { dispatchCount };
1876
+ },
1877
+ async operationalReport(ctx) {
1878
+ return buildSchedulerOperationalReport({
1879
+ nowMs: ctx.nowMs,
1880
+ store: createSchedulerOperationalStore(ctx.state)
1881
+ });
1653
1882
  }
1654
1883
  }
1655
1884
  });
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentPluginState } from "@sentry/junior-plugin-api";
1
+ import type { AgentPluginReadState, AgentPluginState } from "@sentry/junior-plugin-api";
2
2
  import type { ScheduledRun, ScheduledTask } from "./types";
3
3
  export interface SchedulerStore {
4
4
  claimDueRun(args: {
@@ -7,6 +7,7 @@ export interface SchedulerStore {
7
7
  getRun(runId: string): Promise<ScheduledRun | undefined>;
8
8
  getTask(taskId: string): Promise<ScheduledTask | undefined>;
9
9
  listIncompleteRuns(): Promise<ScheduledRun[]>;
10
+ listTasks(): Promise<ScheduledTask[]>;
10
11
  listTasksForTeam(teamId: string): Promise<ScheduledTask[]>;
11
12
  markRunBlocked(args: {
12
13
  completedAtMs: number;
@@ -45,5 +46,11 @@ export interface SchedulerStore {
45
46
  status: "blocked" | "completed" | "failed";
46
47
  }): Promise<void>;
47
48
  }
49
+ export interface SchedulerOperationalStore {
50
+ listIncompleteRunsForTasks(tasks: ScheduledTask[]): Promise<ScheduledRun[]>;
51
+ listTasks(): Promise<ScheduledTask[]>;
52
+ }
48
53
  /** Create a scheduler store backed by this plugin's durable state namespace. */
49
54
  export declare function createSchedulerStore(state: AgentPluginState): SchedulerStore;
55
+ /** Create a read-only scheduler store for operational reporting. */
56
+ export declare function createSchedulerOperationalStore(state: AgentPluginReadState): SchedulerOperationalStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-scheduler",
3
- "version": "0.66.3",
3
+ "version": "0.67.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "@sinclair/typebox": "^0.34.49",
26
- "@sentry/junior-plugin-api": "0.66.3"
26
+ "@sentry/junior-plugin-api": "0.67.1"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^25.9.1",
@@ -0,0 +1,64 @@
1
+ import type { ScheduledTaskPrincipal } from "./types";
2
+
3
+ const SLACK_USER_ID_PATTERN = /^[UW][A-Z0-9]{5,}$/;
4
+
5
+ function clean(value: string | undefined): string | undefined {
6
+ const normalized = value?.trim();
7
+ return normalized ? normalized : undefined;
8
+ }
9
+
10
+ function cleanSlackUserId(value: string | undefined): string | undefined {
11
+ const normalized = clean(value);
12
+ return normalized && normalized.toLowerCase() !== "unknown"
13
+ ? normalized
14
+ : undefined;
15
+ }
16
+
17
+ function cleanDisplay(
18
+ value: string | undefined,
19
+ slackUserId: string,
20
+ ): string | undefined {
21
+ const normalized = clean(value);
22
+ if (
23
+ !normalized ||
24
+ normalized.toLowerCase() === "unknown" ||
25
+ normalized === slackUserId
26
+ ) {
27
+ return undefined;
28
+ }
29
+ return SLACK_USER_ID_PATTERN.test(normalized) ? undefined : normalized;
30
+ }
31
+
32
+ /** Keep scheduler creator metadata from promoting Slack IDs into display names. */
33
+ export function sanitizeScheduledTaskPrincipal(
34
+ principal: ScheduledTaskPrincipal,
35
+ ): ScheduledTaskPrincipal {
36
+ const slackUserId = cleanSlackUserId(principal.slackUserId);
37
+ if (!slackUserId) {
38
+ throw new Error("Scheduled task principal requires a Slack user id");
39
+ }
40
+ const fullName = cleanDisplay(principal.fullName, slackUserId);
41
+ const userName = cleanDisplay(principal.userName, slackUserId);
42
+ return {
43
+ slackUserId,
44
+ ...(fullName ? { fullName } : {}),
45
+ ...(userName ? { userName } : {}),
46
+ };
47
+ }
48
+
49
+ /** Render scheduler creator metadata without inventing human profile fields. */
50
+ export function scheduledTaskPrincipalLabel(
51
+ principal: ScheduledTaskPrincipal,
52
+ ): string {
53
+ const author = sanitizeScheduledTaskPrincipal(principal);
54
+ if (author.fullName && author.userName) {
55
+ return `${author.fullName} (@${author.userName})`;
56
+ }
57
+ if (author.fullName) {
58
+ return author.fullName;
59
+ }
60
+ if (author.userName) {
61
+ return `@${author.userName}`;
62
+ }
63
+ return `Slack User ${author.slackUserId}`;
64
+ }
package/src/plugin.ts CHANGED
@@ -2,11 +2,22 @@ import {
2
2
  defineJuniorPlugin,
3
3
  type Dispatch,
4
4
  type AgentPluginToolDefinition,
5
+ type PluginOperationalReportContent,
5
6
  type ToolRegistrationHookContext,
6
7
  } from "@sentry/junior-plugin-api";
7
8
  import { buildScheduledTaskRunPrompt } from "./prompt";
8
- import { createSchedulerStore, type SchedulerStore } from "./store";
9
- import type { ScheduledRun, ScheduledTask } from "./types";
9
+ import {
10
+ createSchedulerOperationalStore,
11
+ createSchedulerStore,
12
+ type SchedulerOperationalStore,
13
+ type SchedulerStore,
14
+ } from "./store";
15
+ import { scheduledTaskPrincipalLabel } from "./identity";
16
+ import type {
17
+ ScheduledRun,
18
+ ScheduledTask,
19
+ ScheduledTaskPrincipal,
20
+ } from "./types";
10
21
  import {
11
22
  createSlackScheduleCreateTaskTool,
12
23
  createSlackScheduleDeleteTaskTool,
@@ -17,6 +28,7 @@ import {
17
28
  } from "./schedule-tools";
18
29
 
19
30
  const SCHEDULER_HEARTBEAT_LIMIT = 10;
31
+ const DASHBOARD_TABLE_LIMIT = 5;
20
32
 
21
33
  function shouldSkipRun(
22
34
  task: ScheduledTask,
@@ -167,6 +179,186 @@ async function failClaimedRun(args: {
167
179
  });
168
180
  }
169
181
 
182
+ function formatCount(value: number): string {
183
+ return String(value);
184
+ }
185
+
186
+ function formatTimestamp(timestampMs: number | undefined): string {
187
+ return typeof timestampMs === "number" && Number.isFinite(timestampMs)
188
+ ? new Date(timestampMs).toISOString()
189
+ : "none";
190
+ }
191
+
192
+ function destinationLabel(destination: ScheduledTask["destination"]): string {
193
+ if (destination.channelId.startsWith("D")) {
194
+ return "Direct Message";
195
+ }
196
+ if (destination.channelId.startsWith("C")) {
197
+ return `Public Channel ${destination.channelId}`;
198
+ }
199
+ if (destination.channelId.startsWith("G")) {
200
+ return `Private Destination ${destination.channelId}`;
201
+ }
202
+ return destination.channelId;
203
+ }
204
+
205
+ function operationalAuthorLabel(author: ScheduledTaskPrincipal): string {
206
+ try {
207
+ return scheduledTaskPrincipalLabel(author);
208
+ } catch {
209
+ return "Invalid Slack creator metadata";
210
+ }
211
+ }
212
+
213
+ function cadenceLabel(task: ScheduledTask): string {
214
+ if (task.schedule.kind === "one_off") {
215
+ return "one-off";
216
+ }
217
+ return task.schedule.recurrence
218
+ ? task.schedule.recurrence.frequency
219
+ : "recurring";
220
+ }
221
+
222
+ function taskStatusCounts(tasks: ScheduledTask[]) {
223
+ return tasks.reduce(
224
+ (counts, task) => ({
225
+ active: counts.active + (task.status === "active" ? 1 : 0),
226
+ blocked: counts.blocked + (task.status === "blocked" ? 1 : 0),
227
+ paused: counts.paused + (task.status === "paused" ? 1 : 0),
228
+ }),
229
+ { active: 0, blocked: 0, paused: 0 },
230
+ );
231
+ }
232
+
233
+ function isDue(task: ScheduledTask, nowMs: number): boolean {
234
+ return (
235
+ task.status === "active" &&
236
+ ((typeof task.runNowAtMs === "number" && task.runNowAtMs <= nowMs) ||
237
+ (typeof task.nextRunAtMs === "number" && task.nextRunAtMs <= nowMs))
238
+ );
239
+ }
240
+
241
+ async function buildSchedulerOperationalReport(args: {
242
+ nowMs: number;
243
+ store: SchedulerOperationalStore;
244
+ }): Promise<PluginOperationalReportContent> {
245
+ const tasks = await args.store.listTasks();
246
+ const incompleteRuns = await args.store.listIncompleteRunsForTasks(tasks);
247
+ const taskById = new Map(tasks.map((task) => [task.id, task]));
248
+ const counts = taskStatusCounts(tasks);
249
+ const dueCount = tasks.filter((task) => isDue(task, args.nowMs)).length;
250
+ const upcomingTasks = tasks
251
+ .filter((task) => task.status === "active" && task.nextRunAtMs)
252
+ .sort((left, right) => left.nextRunAtMs! - right.nextRunAtMs!)
253
+ .slice(0, DASHBOARD_TABLE_LIMIT);
254
+ const blockedTasks = tasks
255
+ .filter((task) => task.status === "blocked")
256
+ .sort((left, right) => right.updatedAtMs - left.updatedAtMs)
257
+ .slice(0, DASHBOARD_TABLE_LIMIT);
258
+ const runningCount = incompleteRuns.length;
259
+ const runningRuns = incompleteRuns
260
+ .sort((left, right) => right.claimedAtMs - left.claimedAtMs)
261
+ .slice(0, DASHBOARD_TABLE_LIMIT);
262
+
263
+ return {
264
+ title: "Scheduler",
265
+ generatedAt: new Date(args.nowMs).toISOString(),
266
+ metrics: [
267
+ {
268
+ label: "active",
269
+ tone: counts.active > 0 ? "good" : "neutral",
270
+ value: formatCount(counts.active),
271
+ },
272
+ {
273
+ label: "blocked",
274
+ tone: counts.blocked > 0 ? "danger" : "neutral",
275
+ value: formatCount(counts.blocked),
276
+ },
277
+ { label: "paused", value: formatCount(counts.paused) },
278
+ {
279
+ label: "due now",
280
+ tone: dueCount > 0 ? "warning" : "neutral",
281
+ value: formatCount(dueCount),
282
+ },
283
+ {
284
+ label: "running",
285
+ tone: runningCount > 0 ? "warning" : "neutral",
286
+ value: formatCount(runningCount),
287
+ },
288
+ ],
289
+ recordSets: [
290
+ {
291
+ title: "Upcoming",
292
+ emptyText: "No active scheduled tasks.",
293
+ fields: [
294
+ { key: "task", label: "Task" },
295
+ { key: "author", label: "Author" },
296
+ { key: "destination", label: "Destination" },
297
+ { key: "nextRun", label: "Next Run" },
298
+ { key: "cadence", label: "Cadence" },
299
+ ],
300
+ records: upcomingTasks.map((task) => ({
301
+ id: task.id,
302
+ values: {
303
+ task: task.id,
304
+ author: operationalAuthorLabel(task.createdBy),
305
+ destination: destinationLabel(task.destination),
306
+ nextRun: formatTimestamp(task.nextRunAtMs),
307
+ cadence: cadenceLabel(task),
308
+ },
309
+ })),
310
+ },
311
+ {
312
+ title: "Blocked",
313
+ emptyText: "No blocked scheduled tasks.",
314
+ fields: [
315
+ { key: "task", label: "Task" },
316
+ { key: "author", label: "Author" },
317
+ { key: "destination", label: "Destination" },
318
+ { key: "updated", label: "Updated" },
319
+ ],
320
+ records: blockedTasks.map((task) => ({
321
+ id: task.id,
322
+ tone: "danger",
323
+ values: {
324
+ task: task.id,
325
+ author: operationalAuthorLabel(task.createdBy),
326
+ destination: destinationLabel(task.destination),
327
+ updated: formatTimestamp(task.updatedAtMs),
328
+ },
329
+ })),
330
+ },
331
+ {
332
+ title: "Running",
333
+ emptyText: "No scheduler runs in flight.",
334
+ fields: [
335
+ { key: "run", label: "Run" },
336
+ { key: "task", label: "Task" },
337
+ { key: "author", label: "Author" },
338
+ { key: "scheduledFor", label: "Scheduled For" },
339
+ { key: "status", label: "Status" },
340
+ ],
341
+ records: runningRuns.map((run) => {
342
+ const task = taskById.get(run.taskId);
343
+ return {
344
+ id: run.id,
345
+ tone: run.status === "pending" ? "warning" : "neutral",
346
+ values: {
347
+ run: run.id,
348
+ task: run.taskId,
349
+ author: task
350
+ ? operationalAuthorLabel(task.createdBy)
351
+ : "Missing scheduled task",
352
+ scheduledFor: formatTimestamp(run.scheduledForMs),
353
+ status: run.status,
354
+ },
355
+ };
356
+ }),
357
+ },
358
+ ],
359
+ };
360
+ }
361
+
170
362
  /** Create Junior's built-in trusted scheduler plugin. */
171
363
  export function createSchedulerPlugin() {
172
364
  return defineJuniorPlugin({
@@ -305,6 +497,12 @@ export function createSchedulerPlugin() {
305
497
 
306
498
  return { dispatchCount };
307
499
  },
500
+ async operationalReport(ctx) {
501
+ return buildSchedulerOperationalReport({
502
+ nowMs: ctx.nowMs,
503
+ store: createSchedulerOperationalStore(ctx.state),
504
+ });
505
+ },
308
506
  },
309
507
  });
310
508
  }
package/src/prompt.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type ScheduledRun,
4
4
  type ScheduledTask,
5
5
  } from "./types";
6
+ import { sanitizeScheduledTaskPrincipal } from "./identity";
6
7
 
7
8
  function escapeXml(value: string): string {
8
9
  return value
@@ -35,7 +36,7 @@ export function buildScheduledTaskRunPrompt(args: {
35
36
  }): string {
36
37
  const { run, task } = args;
37
38
  const destination = task.destination;
38
- const creator = task.createdBy;
39
+ const creator = sanitizeScheduledTaskPrincipal(task.createdBy);
39
40
  const executionActor = task.executionActor ?? SCHEDULED_TASK_SYSTEM_ACTOR;
40
41
  if (!task.task.text?.trim()) {
41
42
  throw new Error("Scheduled task text is required");
@@ -7,6 +7,7 @@ import {
7
7
  type AgentPluginToolDefinition,
8
8
  } from "@sentry/junior-plugin-api";
9
9
  import { buildCalendarRecurrence, parseScheduleTimestamp } from "./cadence";
10
+ import { sanitizeScheduledTaskPrincipal } from "./identity";
10
11
  import { createSchedulerStore } from "./store";
11
12
  import { SCHEDULED_TASK_SYSTEM_ACTOR } from "./types";
12
13
  import type {
@@ -68,12 +69,12 @@ function requireActiveDestination(
68
69
  function requireRequester(
69
70
  context: SchedulerToolContext,
70
71
  ): ScheduledTaskPrincipal {
71
- const userId = context.requester?.userId;
72
- if (!userId) {
72
+ const userId = context.requester?.userId?.trim();
73
+ if (!userId || userId.toLowerCase() === "unknown") {
73
74
  throwToolInputError("No active Slack requester context is available.");
74
75
  }
75
76
 
76
- return {
77
+ return sanitizeScheduledTaskPrincipal({
77
78
  slackUserId: userId,
78
79
  ...(context.requester?.userName
79
80
  ? { userName: context.requester.userName }
@@ -81,7 +82,7 @@ function requireRequester(
81
82
  ...(context.requester?.fullName
82
83
  ? { fullName: context.requester.fullName }
83
84
  : {}),
84
- };
85
+ });
85
86
  }
86
87
 
87
88
  function tool<TInput = any>(
package/src/store.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { AgentPluginState } from "@sentry/junior-plugin-api";
1
+ import type {
2
+ AgentPluginReadState,
3
+ AgentPluginState,
4
+ } from "@sentry/junior-plugin-api";
2
5
  import { getNextRunAtMs } from "./cadence";
3
6
  import type { ScheduledRun, ScheduledTask } from "./types";
4
7
 
@@ -15,6 +18,7 @@ export interface SchedulerStore {
15
18
  getRun(runId: string): Promise<ScheduledRun | undefined>;
16
19
  getTask(taskId: string): Promise<ScheduledTask | undefined>;
17
20
  listIncompleteRuns(): Promise<ScheduledRun[]>;
21
+ listTasks(): Promise<ScheduledTask[]>;
18
22
  listTasksForTeam(teamId: string): Promise<ScheduledTask[]>;
19
23
  markRunBlocked(args: {
20
24
  completedAtMs: number;
@@ -54,6 +58,11 @@ export interface SchedulerStore {
54
58
  }): Promise<void>;
55
59
  }
56
60
 
61
+ export interface SchedulerOperationalStore {
62
+ listIncompleteRunsForTasks(tasks: ScheduledTask[]): Promise<ScheduledRun[]>;
63
+ listTasks(): Promise<ScheduledTask[]>;
64
+ }
65
+
57
66
  function taskKey(taskId: string): string {
58
67
  return `${SCHEDULER_KEY_PREFIX}:task:${taskId}`;
59
68
  }
@@ -139,7 +148,7 @@ async function removeFromIndex(
139
148
  }
140
149
 
141
150
  async function getIndex(
142
- state: AgentPluginState,
151
+ state: AgentPluginReadState,
143
152
  key: string,
144
153
  ): Promise<string[]> {
145
154
  const values = (await state.get<string[]>(key)) ?? [];
@@ -346,6 +355,68 @@ function canFinishRun(
346
355
  return run.status === "running" && run.startedAtMs === startedAtMs;
347
356
  }
348
357
 
358
+ async function getTaskFromState(
359
+ state: AgentPluginReadState,
360
+ taskId: string,
361
+ ): Promise<ScheduledTask | undefined> {
362
+ return (await state.get<ScheduledTask>(taskKey(taskId))) ?? undefined;
363
+ }
364
+
365
+ async function listTasksFromState(
366
+ state: AgentPluginReadState,
367
+ indexKey: string,
368
+ ): Promise<ScheduledTask[]> {
369
+ const ids = await getIndex(state, indexKey);
370
+ const tasks = await Promise.all(ids.map((id) => getTaskFromState(state, id)));
371
+ return tasks
372
+ .filter((task): task is ScheduledTask => Boolean(task))
373
+ .filter((task) => task.status !== "deleted")
374
+ .sort((a, b) => a.createdAtMs - b.createdAtMs);
375
+ }
376
+
377
+ async function getRunFromState(
378
+ state: AgentPluginReadState,
379
+ runId: string,
380
+ ): Promise<ScheduledRun | undefined> {
381
+ return (await state.get<ScheduledRun>(runKey(runId))) ?? undefined;
382
+ }
383
+
384
+ async function listIncompleteRunsForTasksFromState(
385
+ state: AgentPluginReadState,
386
+ tasks: ScheduledTask[],
387
+ ): Promise<ScheduledRun[]> {
388
+ const runs: ScheduledRun[] = [];
389
+ for (const task of tasks) {
390
+ const active = await state.get<{ runId?: unknown }>(activeRunKey(task.id));
391
+ if (typeof active?.runId !== "string") {
392
+ continue;
393
+ }
394
+ const run = await getRunFromState(state, active.runId);
395
+ if (run && !isFinishedRun(run)) {
396
+ runs.push(run);
397
+ }
398
+ }
399
+ return runs;
400
+ }
401
+
402
+ class PluginStateSchedulerOperationalStore implements SchedulerOperationalStore {
403
+ private readonly state: AgentPluginReadState;
404
+
405
+ constructor(state: AgentPluginReadState) {
406
+ this.state = state;
407
+ }
408
+
409
+ async listTasks(): Promise<ScheduledTask[]> {
410
+ return await listTasksFromState(this.state, globalTaskIndexKey());
411
+ }
412
+
413
+ async listIncompleteRunsForTasks(
414
+ tasks: ScheduledTask[],
415
+ ): Promise<ScheduledRun[]> {
416
+ return await listIncompleteRunsForTasksFromState(this.state, tasks);
417
+ }
418
+ }
419
+
349
420
  class PluginStateSchedulerStore implements SchedulerStore {
350
421
  private readonly state: AgentPluginState;
351
422
 
@@ -408,16 +479,15 @@ class PluginStateSchedulerStore implements SchedulerStore {
408
479
  }
409
480
 
410
481
  async getTask(taskId: string): Promise<ScheduledTask | undefined> {
411
- return (await this.state.get<ScheduledTask>(taskKey(taskId))) ?? undefined;
482
+ return await getTaskFromState(this.state, taskId);
483
+ }
484
+
485
+ async listTasks(): Promise<ScheduledTask[]> {
486
+ return await listTasksFromState(this.state, globalTaskIndexKey());
412
487
  }
413
488
 
414
489
  async listTasksForTeam(teamId: string): Promise<ScheduledTask[]> {
415
- const ids = await getIndex(this.state, teamTaskIndexKey(teamId));
416
- const tasks = await Promise.all(ids.map((id) => this.getTask(id)));
417
- return tasks
418
- .filter((task): task is ScheduledTask => Boolean(task))
419
- .filter((task) => task.status !== "deleted")
420
- .sort((a, b) => a.createdAtMs - b.createdAtMs);
490
+ return await listTasksFromState(this.state, teamTaskIndexKey(teamId));
421
491
  }
422
492
 
423
493
  async claimDueRun(args: {
@@ -576,25 +646,12 @@ class PluginStateSchedulerStore implements SchedulerStore {
576
646
  }
577
647
 
578
648
  async getRun(runId: string): Promise<ScheduledRun | undefined> {
579
- return (await this.state.get<ScheduledRun>(runKey(runId))) ?? undefined;
649
+ return await getRunFromState(this.state, runId);
580
650
  }
581
651
 
582
652
  async listIncompleteRuns(): Promise<ScheduledRun[]> {
583
- const ids = await getIndex(this.state, globalTaskIndexKey());
584
- const runs: ScheduledRun[] = [];
585
- for (const taskId of ids) {
586
- const active = await this.state.get<{ runId?: unknown }>(
587
- activeRunKey(taskId),
588
- );
589
- if (typeof active?.runId !== "string") {
590
- continue;
591
- }
592
- const run = await this.getRun(active.runId);
593
- if (run && !isFinishedRun(run)) {
594
- runs.push(run);
595
- }
596
- }
597
- return runs;
653
+ const tasks = await this.listTasks();
654
+ return await listIncompleteRunsForTasksFromState(this.state, tasks);
598
655
  }
599
656
 
600
657
  async markRunDispatched(args: {
@@ -817,3 +874,10 @@ class PluginStateSchedulerStore implements SchedulerStore {
817
874
  export function createSchedulerStore(state: AgentPluginState): SchedulerStore {
818
875
  return new PluginStateSchedulerStore(state);
819
876
  }
877
+
878
+ /** Create a read-only scheduler store for operational reporting. */
879
+ export function createSchedulerOperationalStore(
880
+ state: AgentPluginReadState,
881
+ ): SchedulerOperationalStore {
882
+ return new PluginStateSchedulerOperationalStore(state);
883
+ }