@sentry/junior-scheduler 0.66.3 → 0.67.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
@@ -606,6 +606,43 @@ function canFinishRun(run, startedAtMs) {
606
606
  }
607
607
  return run.status === "running" && run.startedAtMs === startedAtMs;
608
608
  }
609
+ async function getTaskFromState(state, taskId) {
610
+ return await state.get(taskKey(taskId)) ?? void 0;
611
+ }
612
+ async function listTasksFromState(state, indexKey) {
613
+ const ids = await getIndex(state, indexKey);
614
+ const tasks = await Promise.all(ids.map((id) => getTaskFromState(state, id)));
615
+ return tasks.filter((task) => Boolean(task)).filter((task) => task.status !== "deleted").sort((a, b) => a.createdAtMs - b.createdAtMs);
616
+ }
617
+ async function getRunFromState(state, runId) {
618
+ return await state.get(runKey(runId)) ?? void 0;
619
+ }
620
+ async function listIncompleteRunsForTasksFromState(state, tasks) {
621
+ const runs = [];
622
+ for (const task of tasks) {
623
+ const active = await state.get(activeRunKey(task.id));
624
+ if (typeof active?.runId !== "string") {
625
+ continue;
626
+ }
627
+ const run = await getRunFromState(state, active.runId);
628
+ if (run && !isFinishedRun(run)) {
629
+ runs.push(run);
630
+ }
631
+ }
632
+ return runs;
633
+ }
634
+ var PluginStateSchedulerOperationalStore = class {
635
+ state;
636
+ constructor(state) {
637
+ this.state = state;
638
+ }
639
+ async listTasks() {
640
+ return await listTasksFromState(this.state, globalTaskIndexKey());
641
+ }
642
+ async listIncompleteRunsForTasks(tasks) {
643
+ return await listIncompleteRunsForTasksFromState(this.state, tasks);
644
+ }
645
+ };
609
646
  var PluginStateSchedulerStore = class {
610
647
  state;
611
648
  constructor(state) {
@@ -653,12 +690,13 @@ var PluginStateSchedulerStore = class {
653
690
  }
654
691
  }
655
692
  async getTask(taskId) {
656
- return await this.state.get(taskKey(taskId)) ?? void 0;
693
+ return await getTaskFromState(this.state, taskId);
694
+ }
695
+ async listTasks() {
696
+ return await listTasksFromState(this.state, globalTaskIndexKey());
657
697
  }
658
698
  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);
699
+ return await listTasksFromState(this.state, teamTaskIndexKey(teamId));
662
700
  }
663
701
  async claimDueRun(args) {
664
702
  const ids = await getIndex(this.state, globalTaskIndexKey());
@@ -772,24 +810,11 @@ var PluginStateSchedulerStore = class {
772
810
  ).sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id)).at(0);
773
811
  }
774
812
  async getRun(runId) {
775
- return await this.state.get(runKey(runId)) ?? void 0;
813
+ return await getRunFromState(this.state, runId);
776
814
  }
777
815
  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;
816
+ const tasks = await this.listTasks();
817
+ return await listIncompleteRunsForTasksFromState(this.state, tasks);
793
818
  }
794
819
  async markRunDispatched(args) {
795
820
  return await this.updateRun(
@@ -938,6 +963,9 @@ var PluginStateSchedulerStore = class {
938
963
  function createSchedulerStore(state) {
939
964
  return new PluginStateSchedulerStore(state);
940
965
  }
966
+ function createSchedulerOperationalStore(state) {
967
+ return new PluginStateSchedulerOperationalStore(state);
968
+ }
941
969
 
942
970
  // src/schedule-tools.ts
943
971
  import { randomUUID } from "crypto";
@@ -1413,6 +1441,7 @@ function createSlackScheduleRunTaskNowTool(context) {
1413
1441
 
1414
1442
  // src/plugin.ts
1415
1443
  var SCHEDULER_HEARTBEAT_LIMIT = 10;
1444
+ var DASHBOARD_TABLE_LIMIT = 5;
1416
1445
  function shouldSkipRun(task, run) {
1417
1446
  if (task.status === "deleted") {
1418
1447
  return `Scheduled task ${task.id} was deleted before the run started.`;
@@ -1531,6 +1560,161 @@ async function failClaimedRun(args) {
1531
1560
  status: "failed"
1532
1561
  });
1533
1562
  }
1563
+ function formatCount(value) {
1564
+ return String(value);
1565
+ }
1566
+ function formatTimestamp(timestampMs) {
1567
+ return typeof timestampMs === "number" && Number.isFinite(timestampMs) ? new Date(timestampMs).toISOString() : "none";
1568
+ }
1569
+ function destinationLabel(destination) {
1570
+ if (destination.channelId.startsWith("D")) {
1571
+ return "Direct Message";
1572
+ }
1573
+ if (destination.channelId.startsWith("C")) {
1574
+ return `Public Channel ${destination.channelId}`;
1575
+ }
1576
+ if (destination.channelId.startsWith("G")) {
1577
+ return `Private Destination ${destination.channelId}`;
1578
+ }
1579
+ return destination.channelId;
1580
+ }
1581
+ function authorLabel(author) {
1582
+ if (author.fullName && author.userName) {
1583
+ return `${author.fullName} (@${author.userName})`;
1584
+ }
1585
+ if (author.fullName) {
1586
+ return author.fullName;
1587
+ }
1588
+ if (author.userName) {
1589
+ return `@${author.userName}`;
1590
+ }
1591
+ return `Slack User ${author.slackUserId}`;
1592
+ }
1593
+ function cadenceLabel(task) {
1594
+ if (task.schedule.kind === "one_off") {
1595
+ return "one-off";
1596
+ }
1597
+ return task.schedule.recurrence ? task.schedule.recurrence.frequency : "recurring";
1598
+ }
1599
+ function taskStatusCounts(tasks) {
1600
+ return tasks.reduce(
1601
+ (counts, task) => ({
1602
+ active: counts.active + (task.status === "active" ? 1 : 0),
1603
+ blocked: counts.blocked + (task.status === "blocked" ? 1 : 0),
1604
+ paused: counts.paused + (task.status === "paused" ? 1 : 0)
1605
+ }),
1606
+ { active: 0, blocked: 0, paused: 0 }
1607
+ );
1608
+ }
1609
+ function isDue(task, nowMs) {
1610
+ return task.status === "active" && (typeof task.runNowAtMs === "number" && task.runNowAtMs <= nowMs || typeof task.nextRunAtMs === "number" && task.nextRunAtMs <= nowMs);
1611
+ }
1612
+ async function buildSchedulerOperationalReport(args) {
1613
+ const tasks = await args.store.listTasks();
1614
+ const incompleteRuns = await args.store.listIncompleteRunsForTasks(tasks);
1615
+ const taskById = new Map(tasks.map((task) => [task.id, task]));
1616
+ const counts = taskStatusCounts(tasks);
1617
+ const dueCount = tasks.filter((task) => isDue(task, args.nowMs)).length;
1618
+ const upcomingTasks = tasks.filter((task) => task.status === "active" && task.nextRunAtMs).sort((left, right) => left.nextRunAtMs - right.nextRunAtMs).slice(0, DASHBOARD_TABLE_LIMIT);
1619
+ const blockedTasks = tasks.filter((task) => task.status === "blocked").sort((left, right) => right.updatedAtMs - left.updatedAtMs).slice(0, DASHBOARD_TABLE_LIMIT);
1620
+ const runningCount = incompleteRuns.length;
1621
+ const runningRuns = incompleteRuns.sort((left, right) => right.claimedAtMs - left.claimedAtMs).slice(0, DASHBOARD_TABLE_LIMIT);
1622
+ return {
1623
+ title: "Scheduler",
1624
+ generatedAt: new Date(args.nowMs).toISOString(),
1625
+ metrics: [
1626
+ {
1627
+ label: "active",
1628
+ tone: counts.active > 0 ? "good" : "neutral",
1629
+ value: formatCount(counts.active)
1630
+ },
1631
+ {
1632
+ label: "blocked",
1633
+ tone: counts.blocked > 0 ? "danger" : "neutral",
1634
+ value: formatCount(counts.blocked)
1635
+ },
1636
+ { label: "paused", value: formatCount(counts.paused) },
1637
+ {
1638
+ label: "due now",
1639
+ tone: dueCount > 0 ? "warning" : "neutral",
1640
+ value: formatCount(dueCount)
1641
+ },
1642
+ {
1643
+ label: "running",
1644
+ tone: runningCount > 0 ? "warning" : "neutral",
1645
+ value: formatCount(runningCount)
1646
+ }
1647
+ ],
1648
+ recordSets: [
1649
+ {
1650
+ title: "Upcoming",
1651
+ emptyText: "No active scheduled tasks.",
1652
+ fields: [
1653
+ { key: "task", label: "Task" },
1654
+ { key: "author", label: "Author" },
1655
+ { key: "destination", label: "Destination" },
1656
+ { key: "nextRun", label: "Next Run" },
1657
+ { key: "cadence", label: "Cadence" }
1658
+ ],
1659
+ records: upcomingTasks.map((task) => ({
1660
+ id: task.id,
1661
+ values: {
1662
+ task: task.id,
1663
+ author: authorLabel(task.createdBy),
1664
+ destination: destinationLabel(task.destination),
1665
+ nextRun: formatTimestamp(task.nextRunAtMs),
1666
+ cadence: cadenceLabel(task)
1667
+ }
1668
+ }))
1669
+ },
1670
+ {
1671
+ title: "Blocked",
1672
+ emptyText: "No blocked scheduled tasks.",
1673
+ fields: [
1674
+ { key: "task", label: "Task" },
1675
+ { key: "author", label: "Author" },
1676
+ { key: "destination", label: "Destination" },
1677
+ { key: "updated", label: "Updated" }
1678
+ ],
1679
+ records: blockedTasks.map((task) => ({
1680
+ id: task.id,
1681
+ tone: "danger",
1682
+ values: {
1683
+ task: task.id,
1684
+ author: authorLabel(task.createdBy),
1685
+ destination: destinationLabel(task.destination),
1686
+ updated: formatTimestamp(task.updatedAtMs)
1687
+ }
1688
+ }))
1689
+ },
1690
+ {
1691
+ title: "Running",
1692
+ emptyText: "No scheduler runs in flight.",
1693
+ fields: [
1694
+ { key: "run", label: "Run" },
1695
+ { key: "task", label: "Task" },
1696
+ { key: "author", label: "Author" },
1697
+ { key: "scheduledFor", label: "Scheduled For" },
1698
+ { key: "status", label: "Status" }
1699
+ ],
1700
+ records: runningRuns.map((run) => {
1701
+ const task = taskById.get(run.taskId);
1702
+ return {
1703
+ id: run.id,
1704
+ tone: run.status === "pending" ? "warning" : "neutral",
1705
+ values: {
1706
+ run: run.id,
1707
+ task: run.taskId,
1708
+ author: task ? authorLabel(task.createdBy) : "unknown",
1709
+ scheduledFor: formatTimestamp(run.scheduledForMs),
1710
+ status: run.status
1711
+ }
1712
+ };
1713
+ })
1714
+ }
1715
+ ]
1716
+ };
1717
+ }
1534
1718
  function createSchedulerPlugin() {
1535
1719
  return defineJuniorPlugin({
1536
1720
  manifest: {
@@ -1650,6 +1834,12 @@ function createSchedulerPlugin() {
1650
1834
  dispatchCount += 1;
1651
1835
  }
1652
1836
  return { dispatchCount };
1837
+ },
1838
+ async operationalReport(ctx) {
1839
+ return buildSchedulerOperationalReport({
1840
+ nowMs: ctx.nowMs,
1841
+ store: createSchedulerOperationalStore(ctx.state)
1842
+ });
1653
1843
  }
1654
1844
  }
1655
1845
  });
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.0",
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.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^25.9.1",
package/src/plugin.ts CHANGED
@@ -2,10 +2,16 @@ 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 {
10
+ createSchedulerOperationalStore,
11
+ createSchedulerStore,
12
+ type SchedulerOperationalStore,
13
+ type SchedulerStore,
14
+ } from "./store";
9
15
  import type { ScheduledRun, ScheduledTask } from "./types";
10
16
  import {
11
17
  createSlackScheduleCreateTaskTool,
@@ -17,6 +23,7 @@ import {
17
23
  } from "./schedule-tools";
18
24
 
19
25
  const SCHEDULER_HEARTBEAT_LIMIT = 10;
26
+ const DASHBOARD_TABLE_LIMIT = 5;
20
27
 
21
28
  function shouldSkipRun(
22
29
  task: ScheduledTask,
@@ -167,6 +174,189 @@ async function failClaimedRun(args: {
167
174
  });
168
175
  }
169
176
 
177
+ function formatCount(value: number): string {
178
+ return String(value);
179
+ }
180
+
181
+ function formatTimestamp(timestampMs: number | undefined): string {
182
+ return typeof timestampMs === "number" && Number.isFinite(timestampMs)
183
+ ? new Date(timestampMs).toISOString()
184
+ : "none";
185
+ }
186
+
187
+ function destinationLabel(destination: ScheduledTask["destination"]): string {
188
+ if (destination.channelId.startsWith("D")) {
189
+ return "Direct Message";
190
+ }
191
+ if (destination.channelId.startsWith("C")) {
192
+ return `Public Channel ${destination.channelId}`;
193
+ }
194
+ if (destination.channelId.startsWith("G")) {
195
+ return `Private Destination ${destination.channelId}`;
196
+ }
197
+ return destination.channelId;
198
+ }
199
+
200
+ function authorLabel(author: ScheduledTask["createdBy"]): string {
201
+ if (author.fullName && author.userName) {
202
+ return `${author.fullName} (@${author.userName})`;
203
+ }
204
+ if (author.fullName) {
205
+ return author.fullName;
206
+ }
207
+ if (author.userName) {
208
+ return `@${author.userName}`;
209
+ }
210
+ return `Slack User ${author.slackUserId}`;
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: authorLabel(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: authorLabel(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 ? authorLabel(task.createdBy) : "unknown",
350
+ scheduledFor: formatTimestamp(run.scheduledForMs),
351
+ status: run.status,
352
+ },
353
+ };
354
+ }),
355
+ },
356
+ ],
357
+ };
358
+ }
359
+
170
360
  /** Create Junior's built-in trusted scheduler plugin. */
171
361
  export function createSchedulerPlugin() {
172
362
  return defineJuniorPlugin({
@@ -305,6 +495,12 @@ export function createSchedulerPlugin() {
305
495
 
306
496
  return { dispatchCount };
307
497
  },
498
+ async operationalReport(ctx) {
499
+ return buildSchedulerOperationalReport({
500
+ nowMs: ctx.nowMs,
501
+ store: createSchedulerOperationalStore(ctx.state),
502
+ });
503
+ },
308
504
  },
309
505
  });
310
506
  }
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
+ }