@hybrd/scheduler 2.0.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/README.md +339 -0
- package/dist/index.cjs +1135 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +112 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +1096 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/index.ts +672 -0
- package/src/scheduler.eval.ts +164 -0
- package/src/sql.js.d.ts +21 -0
- package/src/store.ts +316 -0
- package/src/tools.ts +394 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import cronParser from "cron-parser"
|
|
3
|
+
const { parseExpression } = cronParser
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ChannelDispatcher,
|
|
7
|
+
CronJob,
|
|
8
|
+
CronJobCreate,
|
|
9
|
+
CronJobPatch,
|
|
10
|
+
CronRunStatus,
|
|
11
|
+
CronSchedule,
|
|
12
|
+
ListPageOptions,
|
|
13
|
+
PaginatedResult,
|
|
14
|
+
SchedulerConfig,
|
|
15
|
+
SchedulerEvent,
|
|
16
|
+
SchedulerStatus,
|
|
17
|
+
TriggerResponse
|
|
18
|
+
} from "@hybrd/types"
|
|
19
|
+
|
|
20
|
+
import { SqliteSchedulerStore } from "./store.js"
|
|
21
|
+
|
|
22
|
+
export { SqliteSchedulerStore, createSqliteStore } from "./store.js"
|
|
23
|
+
export type { SqliteSchedulerStoreOptions } from "./store.js"
|
|
24
|
+
|
|
25
|
+
export { createSchedulerTools } from "./tools.js"
|
|
26
|
+
export type { SchedulerTool } from "./tools.js"
|
|
27
|
+
|
|
28
|
+
const MAX_TIMER_DELAY_MS = 60_000
|
|
29
|
+
const MIN_REFIRE_GAP_MS = 2_000
|
|
30
|
+
const STUCK_RUN_MS = 2 * 60 * 60 * 1000
|
|
31
|
+
const MAX_SCHEDULE_ERRORS = 3
|
|
32
|
+
|
|
33
|
+
const ERROR_BACKOFF_SCHEDULE_MS = [
|
|
34
|
+
30_000,
|
|
35
|
+
60_000,
|
|
36
|
+
5 * 60_000,
|
|
37
|
+
15 * 60_000,
|
|
38
|
+
60 * 60_000
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
function errorBackoffMs(consecutiveErrors: number): number {
|
|
42
|
+
const idx = Math.min(
|
|
43
|
+
consecutiveErrors - 1,
|
|
44
|
+
ERROR_BACKOFF_SCHEDULE_MS.length - 1
|
|
45
|
+
)
|
|
46
|
+
return ERROR_BACKOFF_SCHEDULE_MS[Math.max(0, idx)] ?? 60_000
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ExecutorResult {
|
|
50
|
+
status: CronRunStatus
|
|
51
|
+
error?: string
|
|
52
|
+
summary?: string
|
|
53
|
+
outputText?: string
|
|
54
|
+
delivered?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SchedulerExecutor {
|
|
58
|
+
runAgentTurn(job: CronJob): Promise<ExecutorResult>
|
|
59
|
+
runSystemEvent(job: CronJob): Promise<ExecutorResult>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SchedulerServiceConfig extends SchedulerConfig {
|
|
63
|
+
store: SqliteSchedulerStore
|
|
64
|
+
dispatcher: ChannelDispatcher
|
|
65
|
+
executor: SchedulerExecutor
|
|
66
|
+
enabled?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface SchedulerState {
|
|
70
|
+
running: boolean
|
|
71
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
72
|
+
op: Promise<unknown>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class SchedulerService {
|
|
76
|
+
private store: SqliteSchedulerStore
|
|
77
|
+
private dispatcher: ChannelDispatcher
|
|
78
|
+
private executor: SchedulerExecutor
|
|
79
|
+
private state: SchedulerState
|
|
80
|
+
private timezone: string
|
|
81
|
+
private enabled: boolean
|
|
82
|
+
private eventCallback?: (event: SchedulerEvent) => void
|
|
83
|
+
|
|
84
|
+
constructor(config: SchedulerServiceConfig) {
|
|
85
|
+
this.store = config.store
|
|
86
|
+
this.dispatcher = config.dispatcher
|
|
87
|
+
this.executor = config.executor
|
|
88
|
+
this.timezone =
|
|
89
|
+
config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
90
|
+
this.enabled = config.enabled ?? true
|
|
91
|
+
this.state = {
|
|
92
|
+
running: false,
|
|
93
|
+
timer: null,
|
|
94
|
+
op: Promise.resolve()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
onEvent(callback: (event: SchedulerEvent) => void): void {
|
|
99
|
+
this.eventCallback = callback
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private emit(event: SchedulerEvent): void {
|
|
103
|
+
this.eventCallback?.(event)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private locked<T>(fn: () => Promise<T>): Promise<T> {
|
|
107
|
+
// Chain onto the previous operation. We use .then() with a no-op
|
|
108
|
+
// rejection handler so that a failure in a prior operation doesn't
|
|
109
|
+
// prevent subsequent operations from running. Each fn's own errors
|
|
110
|
+
// propagate to its caller via the returned promise.
|
|
111
|
+
const next = this.state.op.then(fn, () => fn())
|
|
112
|
+
// Keep the chain alive even if fn rejects — the next queued
|
|
113
|
+
// operation should still run after this one settles.
|
|
114
|
+
this.state.op = next.catch(() => {})
|
|
115
|
+
return next
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private computeNextRunAtMs(
|
|
119
|
+
schedule: CronSchedule,
|
|
120
|
+
nowMs: number
|
|
121
|
+
): number | undefined {
|
|
122
|
+
try {
|
|
123
|
+
switch (schedule.kind) {
|
|
124
|
+
case "at": {
|
|
125
|
+
const atMs = new Date(schedule.at).getTime()
|
|
126
|
+
if (Number.isNaN(atMs)) return undefined
|
|
127
|
+
return atMs > nowMs ? atMs : undefined
|
|
128
|
+
}
|
|
129
|
+
case "every": {
|
|
130
|
+
const everyMs = Math.max(1, Math.floor(schedule.everyMs))
|
|
131
|
+
const anchorMs = schedule.anchorMs ?? nowMs
|
|
132
|
+
const elapsed = Math.max(0, nowMs - anchorMs)
|
|
133
|
+
const steps = Math.max(
|
|
134
|
+
1,
|
|
135
|
+
Math.floor((elapsed + everyMs - 1) / everyMs)
|
|
136
|
+
)
|
|
137
|
+
return anchorMs + steps * everyMs
|
|
138
|
+
}
|
|
139
|
+
case "cron": {
|
|
140
|
+
const interval = parseExpression(schedule.expr, {
|
|
141
|
+
tz: schedule.tz ?? this.timezone
|
|
142
|
+
})
|
|
143
|
+
const next = interval.next()
|
|
144
|
+
return next ? next.getTime() : undefined
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private computeJobNextRunAtMs(
|
|
153
|
+
job: CronJob,
|
|
154
|
+
nowMs: number
|
|
155
|
+
): number | undefined {
|
|
156
|
+
if (!job.enabled) return undefined
|
|
157
|
+
|
|
158
|
+
if (job.schedule.kind === "every") {
|
|
159
|
+
const lastRunAtMs = job.state.lastRunAtMs
|
|
160
|
+
if (typeof lastRunAtMs === "number" && Number.isFinite(lastRunAtMs)) {
|
|
161
|
+
const nextFromLastRun = Math.floor(lastRunAtMs) + job.schedule.everyMs
|
|
162
|
+
if (nextFromLastRun > nowMs) {
|
|
163
|
+
return nextFromLastRun
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (job.schedule.kind === "at") {
|
|
169
|
+
if (job.state.lastRunStatus === "ok") {
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return this.computeNextRunAtMs(job.schedule, nowMs)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private isRunnableJob(job: CronJob, nowMs: number): boolean {
|
|
178
|
+
if (!job.enabled) return false
|
|
179
|
+
|
|
180
|
+
if (typeof job.state.runningAtMs === "number") {
|
|
181
|
+
if (nowMs - job.state.runningAtMs > STUCK_RUN_MS) {
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const next = job.state.nextRunAtMs
|
|
188
|
+
return typeof next === "number" && nowMs >= next
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private findDueJobs(nowMs: number): CronJob[] {
|
|
192
|
+
const jobs = this.store.getAllJobsSync()
|
|
193
|
+
return jobs.filter((job) => this.isRunnableJob(job, nowMs))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private nextWakeAtMs(): number | undefined {
|
|
197
|
+
const jobs = this.store.getAllJobsSync()
|
|
198
|
+
let min: number | undefined
|
|
199
|
+
|
|
200
|
+
for (const job of jobs) {
|
|
201
|
+
if (!job.enabled) continue
|
|
202
|
+
if (job.state.runningAtMs !== undefined) continue
|
|
203
|
+
const next = job.state.nextRunAtMs
|
|
204
|
+
if (typeof next !== "number") continue
|
|
205
|
+
if (min === undefined || next < min) {
|
|
206
|
+
min = next
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return min
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private armTimer(): void {
|
|
214
|
+
this.stopTimer()
|
|
215
|
+
|
|
216
|
+
if (!this.enabled) return
|
|
217
|
+
|
|
218
|
+
const nextAt = this.nextWakeAtMs()
|
|
219
|
+
if (!nextAt) return
|
|
220
|
+
|
|
221
|
+
const now = Date.now()
|
|
222
|
+
const delay = Math.max(0, nextAt - now)
|
|
223
|
+
const clampedDelay = Math.min(delay, MAX_TIMER_DELAY_MS)
|
|
224
|
+
|
|
225
|
+
this.state.timer = setTimeout(() => {
|
|
226
|
+
void this.onTimer()
|
|
227
|
+
}, clampedDelay)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private stopTimer(): void {
|
|
231
|
+
if (this.state.timer) {
|
|
232
|
+
clearTimeout(this.state.timer)
|
|
233
|
+
this.state.timer = null
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async onTimer(): Promise<void> {
|
|
238
|
+
if (this.state.running) {
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.state.running = true
|
|
243
|
+
this.stopTimer()
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const dueJobs = await this.locked(async () => {
|
|
247
|
+
const now = Date.now()
|
|
248
|
+
const due = this.findDueJobs(now)
|
|
249
|
+
|
|
250
|
+
if (due.length === 0) {
|
|
251
|
+
this.recomputeNextRunsForMaintenance()
|
|
252
|
+
return []
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const job of due) {
|
|
256
|
+
job.state.runningAtMs = now
|
|
257
|
+
job.state.lastError = undefined
|
|
258
|
+
}
|
|
259
|
+
this.store.saveAllJobsSync()
|
|
260
|
+
|
|
261
|
+
return due
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
for (const job of dueJobs) {
|
|
265
|
+
await this.executeJob(job)
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
this.state.running = false
|
|
269
|
+
this.armTimer()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private recomputeNextRunsForMaintenance(): boolean {
|
|
274
|
+
const jobs = this.store.getAllJobsSync()
|
|
275
|
+
const now = Date.now()
|
|
276
|
+
let changed = false
|
|
277
|
+
|
|
278
|
+
for (const job of jobs) {
|
|
279
|
+
if (!job.enabled) {
|
|
280
|
+
if (job.state.nextRunAtMs !== undefined) {
|
|
281
|
+
job.state.nextRunAtMs = undefined
|
|
282
|
+
changed = true
|
|
283
|
+
}
|
|
284
|
+
if (job.state.runningAtMs !== undefined) {
|
|
285
|
+
job.state.runningAtMs = undefined
|
|
286
|
+
changed = true
|
|
287
|
+
}
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const runningAt = job.state.runningAtMs
|
|
292
|
+
if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
|
|
293
|
+
job.state.runningAtMs = undefined
|
|
294
|
+
changed = true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (job.state.nextRunAtMs === undefined) {
|
|
298
|
+
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now)
|
|
299
|
+
changed = true
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (changed) {
|
|
304
|
+
this.store.saveAllJobsSync()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return changed
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private applyJobResult(
|
|
311
|
+
job: CronJob,
|
|
312
|
+
result: {
|
|
313
|
+
status: CronRunStatus
|
|
314
|
+
error?: string
|
|
315
|
+
delivered?: boolean
|
|
316
|
+
startedAt: number
|
|
317
|
+
endedAt: number
|
|
318
|
+
}
|
|
319
|
+
): boolean {
|
|
320
|
+
job.state.runningAtMs = undefined
|
|
321
|
+
job.state.lastRunAtMs = result.startedAt
|
|
322
|
+
job.state.lastRunStatus = result.status
|
|
323
|
+
job.state.lastDurationMs = result.endedAt - result.startedAt
|
|
324
|
+
job.state.lastError = result.error
|
|
325
|
+
|
|
326
|
+
if (result.status === "error") {
|
|
327
|
+
job.state.consecutiveErrors = (job.state.consecutiveErrors ?? 0) + 1
|
|
328
|
+
} else {
|
|
329
|
+
job.state.consecutiveErrors = 0
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const shouldDelete =
|
|
333
|
+
job.schedule.kind === "at" &&
|
|
334
|
+
job.deleteAfterRun === true &&
|
|
335
|
+
result.status === "ok"
|
|
336
|
+
|
|
337
|
+
if (!shouldDelete) {
|
|
338
|
+
if (job.schedule.kind === "at") {
|
|
339
|
+
job.enabled = false
|
|
340
|
+
job.state.nextRunAtMs = undefined
|
|
341
|
+
} else if (result.status === "error" && job.enabled) {
|
|
342
|
+
const backoff = errorBackoffMs(job.state.consecutiveErrors ?? 1)
|
|
343
|
+
const normalNext = this.computeJobNextRunAtMs(job, result.endedAt)
|
|
344
|
+
const backoffNext = result.endedAt + backoff
|
|
345
|
+
job.state.nextRunAtMs = normalNext
|
|
346
|
+
? Math.max(normalNext, backoffNext)
|
|
347
|
+
: backoffNext
|
|
348
|
+
} else if (job.enabled) {
|
|
349
|
+
const naturalNext = this.computeJobNextRunAtMs(job, result.endedAt)
|
|
350
|
+
if (job.schedule.kind === "cron") {
|
|
351
|
+
const minNext = result.endedAt + MIN_REFIRE_GAP_MS
|
|
352
|
+
job.state.nextRunAtMs = naturalNext
|
|
353
|
+
? Math.max(naturalNext, minNext)
|
|
354
|
+
: minNext
|
|
355
|
+
} else {
|
|
356
|
+
job.state.nextRunAtMs = naturalNext
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return shouldDelete
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async executeJob(job: CronJob): Promise<void> {
|
|
365
|
+
const startedAt = Date.now()
|
|
366
|
+
this.emit({ jobId: job.id, action: "started", runAtMs: startedAt })
|
|
367
|
+
|
|
368
|
+
let result: ExecutorResult
|
|
369
|
+
try {
|
|
370
|
+
if (job.sessionTarget === "main") {
|
|
371
|
+
result = await this.executor.runSystemEvent(job)
|
|
372
|
+
} else {
|
|
373
|
+
result = await this.executor.runAgentTurn(job)
|
|
374
|
+
}
|
|
375
|
+
} catch (err) {
|
|
376
|
+
result = {
|
|
377
|
+
status: "error",
|
|
378
|
+
error: err instanceof Error ? err.message : String(err)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const endedAt = Date.now()
|
|
383
|
+
|
|
384
|
+
if (
|
|
385
|
+
job.delivery?.mode === "announce" &&
|
|
386
|
+
result.summary &&
|
|
387
|
+
job.delivery.channel &&
|
|
388
|
+
job.delivery.to
|
|
389
|
+
) {
|
|
390
|
+
const deliveryResult: TriggerResponse = await this.dispatcher.dispatch({
|
|
391
|
+
channel: job.delivery.channel,
|
|
392
|
+
to: job.delivery.to,
|
|
393
|
+
message: result.summary
|
|
394
|
+
})
|
|
395
|
+
result.delivered = deliveryResult.delivered
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await this.locked(async () => {
|
|
399
|
+
const currentJob = this.store.getJobSync(job.id)
|
|
400
|
+
if (!currentJob) return
|
|
401
|
+
|
|
402
|
+
const shouldDelete = this.applyJobResult(currentJob, {
|
|
403
|
+
status: result.status,
|
|
404
|
+
error: result.error,
|
|
405
|
+
delivered: result.delivered,
|
|
406
|
+
startedAt,
|
|
407
|
+
endedAt
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
this.emit({
|
|
411
|
+
jobId: currentJob.id,
|
|
412
|
+
action: "finished",
|
|
413
|
+
status: result.status,
|
|
414
|
+
error: result.error,
|
|
415
|
+
delivered: result.delivered,
|
|
416
|
+
runAtMs: startedAt,
|
|
417
|
+
durationMs: currentJob.state.lastDurationMs,
|
|
418
|
+
nextRunAtMs: currentJob.state.nextRunAtMs
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
if (shouldDelete) {
|
|
422
|
+
this.store.deleteJobSync(currentJob.id)
|
|
423
|
+
this.emit({ jobId: currentJob.id, action: "removed" })
|
|
424
|
+
} else {
|
|
425
|
+
this.store.saveJobSync(currentJob)
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async start(): Promise<void> {
|
|
431
|
+
if (!this.enabled) {
|
|
432
|
+
console.log("[scheduler] disabled")
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await this.locked(async () => {
|
|
437
|
+
const jobs = this.store.getAllJobsSync()
|
|
438
|
+
for (const job of jobs) {
|
|
439
|
+
if (typeof job.state.runningAtMs === "number") {
|
|
440
|
+
job.state.runningAtMs = undefined
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this.store.saveAllJobsSync()
|
|
444
|
+
|
|
445
|
+
this.recomputeNextRunsForMaintenance()
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
this.armTimer()
|
|
449
|
+
console.log("[scheduler] started")
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async stop(): Promise<void> {
|
|
453
|
+
this.stopTimer()
|
|
454
|
+
this.state.running = false
|
|
455
|
+
// Flush any pending writes to disk before shutting down.
|
|
456
|
+
// The store uses a 1-second debounced save, so without this
|
|
457
|
+
// the last batch of job state updates could be lost on exit.
|
|
458
|
+
await this.store.close()
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async status(): Promise<SchedulerStatus> {
|
|
462
|
+
return this.locked(async () => {
|
|
463
|
+
const jobs = this.store.getAllJobsSync()
|
|
464
|
+
return {
|
|
465
|
+
enabled: this.enabled,
|
|
466
|
+
jobs: jobs.length,
|
|
467
|
+
nextWakeAtMs: this.nextWakeAtMs() ?? null
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async add(input: CronJobCreate): Promise<CronJob> {
|
|
473
|
+
return this.locked(async () => {
|
|
474
|
+
const now = Date.now()
|
|
475
|
+
const id = input.id ?? randomUUID()
|
|
476
|
+
|
|
477
|
+
const job: CronJob = {
|
|
478
|
+
id,
|
|
479
|
+
agentId: input.agentId,
|
|
480
|
+
sessionKey: input.sessionKey,
|
|
481
|
+
name: input.name,
|
|
482
|
+
description: input.description,
|
|
483
|
+
enabled: input.enabled ?? true,
|
|
484
|
+
deleteAfterRun: input.deleteAfterRun,
|
|
485
|
+
createdAtMs: now,
|
|
486
|
+
updatedAtMs: now,
|
|
487
|
+
schedule: input.schedule,
|
|
488
|
+
sessionTarget: input.sessionTarget ?? "isolated",
|
|
489
|
+
wakeMode: input.wakeMode ?? "now",
|
|
490
|
+
payload: input.payload,
|
|
491
|
+
delivery: input.delivery,
|
|
492
|
+
state: {
|
|
493
|
+
...input.state,
|
|
494
|
+
nextRunAtMs: undefined
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now)
|
|
499
|
+
|
|
500
|
+
this.store.saveJobSync(job)
|
|
501
|
+
this.armTimer()
|
|
502
|
+
|
|
503
|
+
this.emit({
|
|
504
|
+
jobId: job.id,
|
|
505
|
+
action: "added",
|
|
506
|
+
nextRunAtMs: job.state.nextRunAtMs
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
return job
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async get(id: string): Promise<CronJob | undefined> {
|
|
514
|
+
return this.store.getJobSync(id)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async list(opts?: { includeDisabled?: boolean }): Promise<CronJob[]> {
|
|
518
|
+
const jobs = this.store.getAllJobsSync()
|
|
519
|
+
const includeDisabled = opts?.includeDisabled === true
|
|
520
|
+
const filtered = jobs.filter((j) => includeDisabled || j.enabled)
|
|
521
|
+
return filtered.sort(
|
|
522
|
+
(a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async listPage(opts?: ListPageOptions): Promise<PaginatedResult<CronJob>> {
|
|
527
|
+
const jobs = this.store.getAllJobsSync()
|
|
528
|
+
|
|
529
|
+
const includeDisabled =
|
|
530
|
+
opts?.enabled === "all" || opts?.enabled === "disabled"
|
|
531
|
+
const includeEnabled = opts?.enabled !== "disabled"
|
|
532
|
+
const query = opts?.query?.trim().toLowerCase() ?? ""
|
|
533
|
+
|
|
534
|
+
const filtered = jobs.filter((job) => {
|
|
535
|
+
if (!includeDisabled && !job.enabled) return false
|
|
536
|
+
if (!includeEnabled && job.enabled) return false
|
|
537
|
+
if (query) {
|
|
538
|
+
const haystack = [job.name, job.description ?? ""]
|
|
539
|
+
.join(" ")
|
|
540
|
+
.toLowerCase()
|
|
541
|
+
return haystack.includes(query)
|
|
542
|
+
}
|
|
543
|
+
return true
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
const sortBy = opts?.sortBy ?? "nextRunAtMs"
|
|
547
|
+
const sortDir = opts?.sortDir ?? "asc"
|
|
548
|
+
const dir = sortDir === "desc" ? -1 : 1
|
|
549
|
+
|
|
550
|
+
filtered.sort((a, b) => {
|
|
551
|
+
let cmp = 0
|
|
552
|
+
if (sortBy === "name") {
|
|
553
|
+
cmp = a.name.localeCompare(b.name)
|
|
554
|
+
} else if (sortBy === "updatedAtMs") {
|
|
555
|
+
cmp = a.updatedAtMs - b.updatedAtMs
|
|
556
|
+
} else {
|
|
557
|
+
const aNext = a.state.nextRunAtMs
|
|
558
|
+
const bNext = b.state.nextRunAtMs
|
|
559
|
+
if (typeof aNext === "number" && typeof bNext === "number") {
|
|
560
|
+
cmp = aNext - bNext
|
|
561
|
+
} else if (typeof aNext === "number") {
|
|
562
|
+
cmp = -1
|
|
563
|
+
} else if (typeof bNext === "number") {
|
|
564
|
+
cmp = 1
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return cmp * dir
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
const total = filtered.length
|
|
571
|
+
const offset = Math.max(0, Math.min(total, opts?.offset ?? 0))
|
|
572
|
+
const limit = Math.max(1, Math.min(200, opts?.limit ?? 50))
|
|
573
|
+
const items = filtered.slice(offset, offset + limit)
|
|
574
|
+
const nextOffset = offset + items.length
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
items,
|
|
578
|
+
total,
|
|
579
|
+
offset,
|
|
580
|
+
limit,
|
|
581
|
+
hasMore: nextOffset < total,
|
|
582
|
+
nextOffset: nextOffset < total ? nextOffset : null
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async update(id: string, patch: CronJobPatch): Promise<CronJob> {
|
|
587
|
+
return this.locked(async () => {
|
|
588
|
+
const job = this.store.getJobSync(id)
|
|
589
|
+
if (!job) {
|
|
590
|
+
throw new Error(`Job not found: ${id}`)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (patch.name !== undefined) job.name = patch.name
|
|
594
|
+
if (patch.description !== undefined) job.description = patch.description
|
|
595
|
+
if (patch.enabled !== undefined) job.enabled = patch.enabled
|
|
596
|
+
if (patch.deleteAfterRun !== undefined)
|
|
597
|
+
job.deleteAfterRun = patch.deleteAfterRun
|
|
598
|
+
if (patch.schedule !== undefined) job.schedule = patch.schedule
|
|
599
|
+
if (patch.sessionTarget !== undefined)
|
|
600
|
+
job.sessionTarget = patch.sessionTarget
|
|
601
|
+
if (patch.wakeMode !== undefined) job.wakeMode = patch.wakeMode
|
|
602
|
+
if (patch.payload !== undefined) job.payload = patch.payload
|
|
603
|
+
if (patch.delivery !== undefined) job.delivery = patch.delivery
|
|
604
|
+
|
|
605
|
+
job.updatedAtMs = Date.now()
|
|
606
|
+
|
|
607
|
+
if (job.enabled) {
|
|
608
|
+
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, job.updatedAtMs)
|
|
609
|
+
} else {
|
|
610
|
+
job.state.nextRunAtMs = undefined
|
|
611
|
+
job.state.runningAtMs = undefined
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
this.store.saveJobSync(job)
|
|
615
|
+
this.armTimer()
|
|
616
|
+
|
|
617
|
+
this.emit({
|
|
618
|
+
jobId: job.id,
|
|
619
|
+
action: "updated",
|
|
620
|
+
nextRunAtMs: job.state.nextRunAtMs
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
return job
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async remove(id: string): Promise<{ ok: boolean; removed: boolean }> {
|
|
628
|
+
return this.locked(async () => {
|
|
629
|
+
const existed = this.store.getJobSync(id) !== undefined
|
|
630
|
+
if (existed) {
|
|
631
|
+
this.store.deleteJobSync(id)
|
|
632
|
+
this.armTimer()
|
|
633
|
+
this.emit({ jobId: id, action: "removed" })
|
|
634
|
+
}
|
|
635
|
+
return { ok: true, removed: existed }
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async run(
|
|
640
|
+
id: string,
|
|
641
|
+
mode?: "due" | "force"
|
|
642
|
+
): Promise<{ ok: boolean; ran: boolean; reason?: string }> {
|
|
643
|
+
const job = this.store.getJobSync(id)
|
|
644
|
+
if (!job) {
|
|
645
|
+
return { ok: false, ran: false }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (typeof job.state.runningAtMs === "number") {
|
|
649
|
+
return { ok: true, ran: false, reason: "already-running" }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const now = Date.now()
|
|
653
|
+
const due =
|
|
654
|
+
mode === "force" ||
|
|
655
|
+
(job.enabled &&
|
|
656
|
+
typeof job.state.nextRunAtMs === "number" &&
|
|
657
|
+
now >= job.state.nextRunAtMs)
|
|
658
|
+
if (!due) {
|
|
659
|
+
return { ok: true, ran: false, reason: "not-due" }
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await this.executeJob(job)
|
|
663
|
+
return { ok: true, ran: true }
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export async function createSchedulerService(
|
|
668
|
+
config: SchedulerServiceConfig
|
|
669
|
+
): Promise<SchedulerService> {
|
|
670
|
+
await config.store.init()
|
|
671
|
+
return new SchedulerService(config)
|
|
672
|
+
}
|