@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 +210 -20
- package/dist/store.d.ts +8 -1
- package/package.json +2 -2
- package/src/plugin.ts +197 -1
- package/src/store.ts +89 -25
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
|
|
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
|
-
|
|
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
|
|
813
|
+
return await getRunFromState(this.state, runId);
|
|
776
814
|
}
|
|
777
815
|
async listIncompleteRuns() {
|
|
778
|
-
const
|
|
779
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
649
|
+
return await getRunFromState(this.state, runId);
|
|
580
650
|
}
|
|
581
651
|
|
|
582
652
|
async listIncompleteRuns(): Promise<ScheduledRun[]> {
|
|
583
|
-
const
|
|
584
|
-
|
|
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
|
+
}
|