@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/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
+ }