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