@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/dist/index.js ADDED
@@ -0,0 +1,1096 @@
1
+ // src/index.ts
2
+ import { randomUUID } from "crypto";
3
+ import cronParser from "cron-parser";
4
+
5
+ // src/store.ts
6
+ import { readFileSync } from "fs";
7
+ import { createRequire } from "module";
8
+ import { dirname, join } from "path";
9
+ import initSqlJs from "sql.js";
10
+ var SCHEMA = `
11
+ CREATE TABLE IF NOT EXISTS cron_jobs (
12
+ id TEXT PRIMARY KEY,
13
+ agent_id TEXT,
14
+ session_key TEXT,
15
+ name TEXT NOT NULL,
16
+ description TEXT,
17
+ enabled INTEGER NOT NULL DEFAULT 1,
18
+ delete_after_run INTEGER,
19
+ created_at_ms INTEGER NOT NULL,
20
+ updated_at_ms INTEGER NOT NULL,
21
+ schedule TEXT NOT NULL,
22
+ session_target TEXT NOT NULL DEFAULT 'isolated',
23
+ wake_mode TEXT NOT NULL DEFAULT 'now',
24
+ payload TEXT NOT NULL,
25
+ delivery TEXT,
26
+ state TEXT NOT NULL
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(id);
30
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled ON cron_jobs(enabled);
31
+ `;
32
+ var SqliteSchedulerStore = class {
33
+ db = null;
34
+ dbPath;
35
+ onSave;
36
+ loadOnInit;
37
+ initPromise = null;
38
+ dirty = false;
39
+ saveTimer;
40
+ jobsCache = null;
41
+ constructor(options = {}) {
42
+ this.dbPath = options.dbPath ?? ":memory:";
43
+ this.onSave = options.onSave;
44
+ this.loadOnInit = options.loadOnInit ?? true;
45
+ }
46
+ async init() {
47
+ if (this.initPromise) {
48
+ return this.initPromise;
49
+ }
50
+ this.initPromise = this._init();
51
+ return this.initPromise;
52
+ }
53
+ async _init() {
54
+ let SQL;
55
+ try {
56
+ const req = createRequire(process.cwd());
57
+ const sqlJsPath = dirname(req.resolve("sql.js"));
58
+ const wasmPath = join(sqlJsPath, "sql-wasm.wasm");
59
+ const wasmBinary = readFileSync(wasmPath);
60
+ SQL = await initSqlJs({ wasmBinary });
61
+ } catch {
62
+ SQL = await initSqlJs();
63
+ }
64
+ if (this.dbPath === ":memory:" || !this.loadOnInit) {
65
+ this.db = new SQL.Database();
66
+ } else {
67
+ try {
68
+ const buffer = readFileSync(this.dbPath);
69
+ this.db = new SQL.Database(new Uint8Array(buffer));
70
+ } catch {
71
+ this.db = new SQL.Database();
72
+ }
73
+ }
74
+ this.db.run(SCHEMA);
75
+ this.loadCache();
76
+ this.scheduleSave();
77
+ }
78
+ loadCache() {
79
+ if (!this.db) return;
80
+ const cache = /* @__PURE__ */ new Map();
81
+ const result = this.db.exec(
82
+ "SELECT id, agent_id, session_key, name, description, enabled, delete_after_run, created_at_ms, updated_at_ms, schedule, session_target, wake_mode, payload, delivery, state FROM cron_jobs"
83
+ );
84
+ const firstResult = result[0];
85
+ if (firstResult) {
86
+ for (const row of firstResult.values) {
87
+ const job = this.rowToJob(row);
88
+ if (job) {
89
+ cache.set(job.id, job);
90
+ }
91
+ }
92
+ }
93
+ this.jobsCache = cache;
94
+ }
95
+ scheduleSave() {
96
+ if (this.saveTimer) {
97
+ clearTimeout(this.saveTimer);
98
+ }
99
+ this.saveTimer = setTimeout(() => {
100
+ void this.save();
101
+ }, 1e3);
102
+ }
103
+ async save() {
104
+ if (!this.db || !this.dirty) return;
105
+ const data = this.db.export();
106
+ this.dirty = false;
107
+ if (this.onSave) {
108
+ await this.onSave(data);
109
+ }
110
+ }
111
+ async close() {
112
+ if (this.saveTimer) {
113
+ clearTimeout(this.saveTimer);
114
+ }
115
+ await this.save();
116
+ this.db?.close();
117
+ this.db = null;
118
+ this.jobsCache = null;
119
+ }
120
+ rowToJob(row) {
121
+ try {
122
+ const schedule = JSON.parse(String(row[9]));
123
+ const payload = JSON.parse(String(row[12]));
124
+ const delivery = row[13] ? JSON.parse(String(row[13])) : void 0;
125
+ const state = JSON.parse(String(row[14]));
126
+ return {
127
+ id: String(row[0]),
128
+ agentId: row[1] ? String(row[1]) : void 0,
129
+ sessionKey: row[2] ? String(row[2]) : void 0,
130
+ name: String(row[3]),
131
+ description: row[4] ? String(row[4]) : void 0,
132
+ enabled: Boolean(row[5]),
133
+ deleteAfterRun: row[6] ? Boolean(row[6]) : void 0,
134
+ createdAtMs: Number(row[7]),
135
+ updatedAtMs: Number(row[8]),
136
+ schedule,
137
+ sessionTarget: row[10] ?? "isolated",
138
+ wakeMode: row[11] ?? "now",
139
+ payload,
140
+ delivery,
141
+ state
142
+ };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+ jobToRow(job) {
148
+ return [
149
+ job.id,
150
+ job.agentId ?? null,
151
+ job.sessionKey ?? null,
152
+ job.name,
153
+ job.description ?? null,
154
+ job.enabled ? 1 : 0,
155
+ job.deleteAfterRun ? 1 : null,
156
+ job.createdAtMs,
157
+ job.updatedAtMs,
158
+ JSON.stringify(job.schedule),
159
+ job.sessionTarget,
160
+ job.wakeMode,
161
+ JSON.stringify(job.payload),
162
+ job.delivery ? JSON.stringify(job.delivery) : null,
163
+ JSON.stringify(job.state)
164
+ ];
165
+ }
166
+ ensureDb() {
167
+ if (!this.db) {
168
+ throw new Error("Store not initialized. Call init() first.");
169
+ }
170
+ return this.db;
171
+ }
172
+ ensureCache() {
173
+ if (!this.jobsCache) {
174
+ throw new Error("Cache not initialized. Call init() first.");
175
+ }
176
+ return this.jobsCache;
177
+ }
178
+ getJobSync(id) {
179
+ const cache = this.ensureCache();
180
+ return cache.get(id);
181
+ }
182
+ getAllJobsSync() {
183
+ const cache = this.ensureCache();
184
+ return Array.from(cache.values());
185
+ }
186
+ saveJobSync(job) {
187
+ const db = this.ensureDb();
188
+ const cache = this.ensureCache();
189
+ db.run(
190
+ `INSERT OR REPLACE INTO cron_jobs
191
+ (id, agent_id, session_key, name, description, enabled, delete_after_run, created_at_ms, updated_at_ms, schedule, session_target, wake_mode, payload, delivery, state)
192
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
193
+ this.jobToRow(job)
194
+ );
195
+ cache.set(job.id, job);
196
+ this.dirty = true;
197
+ this.scheduleSave();
198
+ }
199
+ saveAllJobsSync() {
200
+ const db = this.ensureDb();
201
+ const cache = this.ensureCache();
202
+ for (const job of cache.values()) {
203
+ db.run(
204
+ `INSERT OR REPLACE INTO cron_jobs
205
+ (id, agent_id, session_key, name, description, enabled, delete_after_run, created_at_ms, updated_at_ms, schedule, session_target, wake_mode, payload, delivery, state)
206
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
207
+ this.jobToRow(job)
208
+ );
209
+ }
210
+ this.dirty = true;
211
+ this.scheduleSave();
212
+ }
213
+ deleteJobSync(id) {
214
+ const db = this.ensureDb();
215
+ const cache = this.ensureCache();
216
+ db.run("DELETE FROM cron_jobs WHERE id = ?", [id]);
217
+ cache.delete(id);
218
+ this.dirty = true;
219
+ this.scheduleSave();
220
+ }
221
+ async getTask(id) {
222
+ return this.getJobSync(id);
223
+ }
224
+ async getAllTasks() {
225
+ return this.getAllJobsSync();
226
+ }
227
+ async saveTask(task) {
228
+ this.saveJobSync(task);
229
+ }
230
+ async deleteTask(id) {
231
+ this.deleteJobSync(id);
232
+ }
233
+ };
234
+ async function createSqliteStore(options) {
235
+ const resolvedOptions = { ...options };
236
+ if (resolvedOptions.dbPath && resolvedOptions.dbPath !== ":memory:" && !resolvedOptions.onSave) {
237
+ const { writeFileSync } = await import("fs");
238
+ const { dirname: dirname2 } = await import("path");
239
+ const { mkdirSync, existsSync } = await import("fs");
240
+ const dbPath = resolvedOptions.dbPath;
241
+ const dir = dirname2(dbPath);
242
+ if (!existsSync(dir)) {
243
+ mkdirSync(dir, { recursive: true });
244
+ }
245
+ resolvedOptions.onSave = (data) => {
246
+ writeFileSync(dbPath, data);
247
+ };
248
+ }
249
+ const store = new SqliteSchedulerStore(resolvedOptions);
250
+ await store.init();
251
+ return store;
252
+ }
253
+
254
+ // src/tools.ts
255
+ import { z } from "zod";
256
+ var ScheduleSchema = z.discriminatedUnion("kind", [
257
+ z.object({
258
+ kind: z.literal("at"),
259
+ at: z.string()
260
+ }),
261
+ z.object({
262
+ kind: z.literal("every"),
263
+ everyMs: z.number().positive(),
264
+ anchorMs: z.number().optional()
265
+ }),
266
+ z.object({
267
+ kind: z.literal("cron"),
268
+ expr: z.string(),
269
+ tz: z.string().optional(),
270
+ staggerMs: z.number().optional()
271
+ })
272
+ ]);
273
+ var PayloadSchema = z.discriminatedUnion("kind", [
274
+ z.object({
275
+ kind: z.literal("systemEvent"),
276
+ text: z.string()
277
+ }),
278
+ z.object({
279
+ kind: z.literal("agentTurn"),
280
+ message: z.string(),
281
+ model: z.string().optional(),
282
+ thinking: z.string().optional(),
283
+ timeoutSeconds: z.number().optional(),
284
+ allowUnsafeExternalContent: z.boolean().optional()
285
+ })
286
+ ]);
287
+ var ScheduleTaskSchema = z.object({
288
+ id: z.string().optional(),
289
+ agentId: z.string().optional(),
290
+ sessionKey: z.string().optional(),
291
+ name: z.string(),
292
+ description: z.string().optional(),
293
+ enabled: z.boolean().optional(),
294
+ deleteAfterRun: z.boolean().optional(),
295
+ schedule: ScheduleSchema,
296
+ sessionTarget: z.enum(["main", "isolated"]).optional(),
297
+ wakeMode: z.enum(["now", "next-heartbeat"]).optional(),
298
+ payload: PayloadSchema,
299
+ delivery: z.object({
300
+ mode: z.enum(["none", "announce"]),
301
+ channel: z.string().optional(),
302
+ to: z.string().optional(),
303
+ accountId: z.string().optional(),
304
+ bestEffort: z.boolean().optional()
305
+ }).optional()
306
+ });
307
+ var ListTasksSchema = z.object({
308
+ includeDisabled: z.boolean().optional(),
309
+ limit: z.number().optional(),
310
+ offset: z.number().optional(),
311
+ query: z.string().optional(),
312
+ enabled: z.enum(["all", "enabled", "disabled"]).optional(),
313
+ sortBy: z.enum(["nextRunAtMs", "updatedAtMs", "name"]).optional(),
314
+ sortDir: z.enum(["asc", "desc"]).optional()
315
+ }).optional();
316
+ var CancelTaskSchema = z.object({
317
+ taskId: z.string()
318
+ });
319
+ var GetTaskSchema = z.object({
320
+ taskId: z.string()
321
+ });
322
+ var RunTaskSchema = z.object({
323
+ taskId: z.string(),
324
+ mode: z.enum(["due", "force"]).optional()
325
+ });
326
+ function formatSchedule(schedule) {
327
+ switch (schedule.kind) {
328
+ case "at":
329
+ return `at ${schedule.at}`;
330
+ case "every":
331
+ return `every ${schedule.everyMs}ms`;
332
+ case "cron":
333
+ return `cron ${schedule.expr}${schedule.tz ? ` (${schedule.tz})` : ""}`;
334
+ }
335
+ }
336
+ function formatJob(job) {
337
+ return {
338
+ id: job.id,
339
+ name: job.name,
340
+ description: job.description,
341
+ enabled: job.enabled,
342
+ schedule: formatSchedule(job.schedule),
343
+ sessionTarget: job.sessionTarget,
344
+ wakeMode: job.wakeMode,
345
+ payload: job.payload,
346
+ delivery: job.delivery,
347
+ state: {
348
+ nextRunAtMs: job.state.nextRunAtMs,
349
+ lastRunAtMs: job.state.lastRunAtMs,
350
+ lastRunStatus: job.state.lastRunStatus,
351
+ lastError: job.state.lastError,
352
+ consecutiveErrors: job.state.consecutiveErrors
353
+ }
354
+ };
355
+ }
356
+ function createSchedulerTools(scheduler) {
357
+ return [
358
+ {
359
+ name: "schedule_task",
360
+ description: `Schedule a task to run at a specific time or interval.
361
+
362
+ Examples:
363
+ - One-time: { schedule: { kind: "at", at: "2026-03-01T09:00:00Z" }, payload: { kind: "agentTurn", message: "Check on the project" } }
364
+ - Interval: { schedule: { kind: "every", everyMs: 300000 }, payload: { kind: "agentTurn", message: "Status check" } }
365
+ - Cron: { schedule: { kind: "cron", expr: "0 9 * * 1-5" }, payload: { kind: "agentTurn", message: "Daily standup reminder" } }`,
366
+ inputSchema: ScheduleTaskSchema,
367
+ handler: async (args) => {
368
+ const parsed = ScheduleTaskSchema.safeParse(args);
369
+ if (!parsed.success) {
370
+ return {
371
+ content: [
372
+ {
373
+ type: "text",
374
+ text: JSON.stringify({
375
+ success: false,
376
+ error: parsed.error.message
377
+ })
378
+ }
379
+ ],
380
+ isError: true
381
+ };
382
+ }
383
+ try {
384
+ const input = {
385
+ id: parsed.data.id,
386
+ agentId: parsed.data.agentId,
387
+ sessionKey: parsed.data.sessionKey,
388
+ name: parsed.data.name,
389
+ description: parsed.data.description,
390
+ enabled: parsed.data.enabled,
391
+ deleteAfterRun: parsed.data.deleteAfterRun,
392
+ schedule: parsed.data.schedule,
393
+ sessionTarget: parsed.data.sessionTarget,
394
+ wakeMode: parsed.data.wakeMode,
395
+ payload: parsed.data.payload,
396
+ delivery: parsed.data.delivery
397
+ };
398
+ const job = await scheduler.add(input);
399
+ return {
400
+ content: [
401
+ {
402
+ type: "text",
403
+ text: JSON.stringify({
404
+ success: true,
405
+ jobId: job.id,
406
+ name: job.name,
407
+ nextRunAtMs: job.state.nextRunAtMs
408
+ })
409
+ }
410
+ ]
411
+ };
412
+ } catch (err) {
413
+ return {
414
+ content: [
415
+ {
416
+ type: "text",
417
+ text: JSON.stringify({
418
+ success: false,
419
+ error: err instanceof Error ? err.message : "Failed to schedule task"
420
+ })
421
+ }
422
+ ],
423
+ isError: true
424
+ };
425
+ }
426
+ }
427
+ },
428
+ {
429
+ name: "list_scheduled_tasks",
430
+ description: "List all scheduled tasks with optional filtering and pagination",
431
+ inputSchema: ListTasksSchema,
432
+ handler: async (args) => {
433
+ const parsed = ListTasksSchema.safeParse(args);
434
+ if (!parsed.success) {
435
+ return {
436
+ content: [
437
+ {
438
+ type: "text",
439
+ text: JSON.stringify({ error: parsed.error.message })
440
+ }
441
+ ],
442
+ isError: true
443
+ };
444
+ }
445
+ const result = await scheduler.listPage(parsed.data);
446
+ return {
447
+ content: [
448
+ {
449
+ type: "text",
450
+ text: JSON.stringify({
451
+ items: result.items.map(formatJob),
452
+ total: result.total,
453
+ offset: result.offset,
454
+ limit: result.limit,
455
+ hasMore: result.hasMore
456
+ })
457
+ }
458
+ ]
459
+ };
460
+ }
461
+ },
462
+ {
463
+ name: "get_scheduled_task",
464
+ description: "Get details of a specific scheduled task",
465
+ inputSchema: GetTaskSchema,
466
+ handler: async (args) => {
467
+ const parsed = GetTaskSchema.safeParse(args);
468
+ if (!parsed.success) {
469
+ return {
470
+ content: [
471
+ {
472
+ type: "text",
473
+ text: JSON.stringify({ error: parsed.error.message })
474
+ }
475
+ ],
476
+ isError: true
477
+ };
478
+ }
479
+ const job = await scheduler.get(parsed.data.taskId);
480
+ if (!job) {
481
+ return {
482
+ content: [
483
+ {
484
+ type: "text",
485
+ text: JSON.stringify({ error: "Task not found" })
486
+ }
487
+ ],
488
+ isError: true
489
+ };
490
+ }
491
+ return {
492
+ content: [
493
+ {
494
+ type: "text",
495
+ text: JSON.stringify(formatJob(job), null, 2)
496
+ }
497
+ ]
498
+ };
499
+ }
500
+ },
501
+ {
502
+ name: "cancel_scheduled_task",
503
+ description: "Cancel and remove a scheduled task",
504
+ inputSchema: CancelTaskSchema,
505
+ handler: async (args) => {
506
+ const parsed = CancelTaskSchema.safeParse(args);
507
+ if (!parsed.success) {
508
+ return {
509
+ content: [
510
+ {
511
+ type: "text",
512
+ text: JSON.stringify({
513
+ success: false,
514
+ error: parsed.error.message
515
+ })
516
+ }
517
+ ],
518
+ isError: true
519
+ };
520
+ }
521
+ try {
522
+ const result = await scheduler.remove(parsed.data.taskId);
523
+ return {
524
+ content: [
525
+ {
526
+ type: "text",
527
+ text: JSON.stringify({
528
+ success: true,
529
+ removed: result.removed
530
+ })
531
+ }
532
+ ]
533
+ };
534
+ } catch (err) {
535
+ return {
536
+ content: [
537
+ {
538
+ type: "text",
539
+ text: JSON.stringify({
540
+ success: false,
541
+ error: err instanceof Error ? err.message : "Failed to cancel task"
542
+ })
543
+ }
544
+ ],
545
+ isError: true
546
+ };
547
+ }
548
+ }
549
+ },
550
+ {
551
+ name: "run_scheduled_task",
552
+ description: "Manually trigger a scheduled task to run now",
553
+ inputSchema: RunTaskSchema,
554
+ handler: async (args) => {
555
+ const parsed = RunTaskSchema.safeParse(args);
556
+ if (!parsed.success) {
557
+ return {
558
+ content: [
559
+ {
560
+ type: "text",
561
+ text: JSON.stringify({
562
+ success: false,
563
+ error: parsed.error.message
564
+ })
565
+ }
566
+ ],
567
+ isError: true
568
+ };
569
+ }
570
+ try {
571
+ const result = await scheduler.run(
572
+ parsed.data.taskId,
573
+ parsed.data.mode
574
+ );
575
+ return {
576
+ content: [
577
+ {
578
+ type: "text",
579
+ text: JSON.stringify(result)
580
+ }
581
+ ]
582
+ };
583
+ } catch (err) {
584
+ return {
585
+ content: [
586
+ {
587
+ type: "text",
588
+ text: JSON.stringify({
589
+ success: false,
590
+ error: err instanceof Error ? err.message : "Failed to run task"
591
+ })
592
+ }
593
+ ],
594
+ isError: true
595
+ };
596
+ }
597
+ }
598
+ }
599
+ ];
600
+ }
601
+
602
+ // src/index.ts
603
+ var { parseExpression } = cronParser;
604
+ var MAX_TIMER_DELAY_MS = 6e4;
605
+ var MIN_REFIRE_GAP_MS = 2e3;
606
+ var STUCK_RUN_MS = 2 * 60 * 60 * 1e3;
607
+ var ERROR_BACKOFF_SCHEDULE_MS = [
608
+ 3e4,
609
+ 6e4,
610
+ 5 * 6e4,
611
+ 15 * 6e4,
612
+ 60 * 6e4
613
+ ];
614
+ function errorBackoffMs(consecutiveErrors) {
615
+ const idx = Math.min(
616
+ consecutiveErrors - 1,
617
+ ERROR_BACKOFF_SCHEDULE_MS.length - 1
618
+ );
619
+ return ERROR_BACKOFF_SCHEDULE_MS[Math.max(0, idx)] ?? 6e4;
620
+ }
621
+ var SchedulerService = class {
622
+ store;
623
+ dispatcher;
624
+ executor;
625
+ state;
626
+ timezone;
627
+ enabled;
628
+ eventCallback;
629
+ constructor(config) {
630
+ this.store = config.store;
631
+ this.dispatcher = config.dispatcher;
632
+ this.executor = config.executor;
633
+ this.timezone = config.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
634
+ this.enabled = config.enabled ?? true;
635
+ this.state = {
636
+ running: false,
637
+ timer: null,
638
+ op: Promise.resolve()
639
+ };
640
+ }
641
+ onEvent(callback) {
642
+ this.eventCallback = callback;
643
+ }
644
+ emit(event) {
645
+ this.eventCallback?.(event);
646
+ }
647
+ locked(fn) {
648
+ const next = this.state.op.then(fn, () => fn());
649
+ this.state.op = next.catch(() => {
650
+ });
651
+ return next;
652
+ }
653
+ computeNextRunAtMs(schedule, nowMs) {
654
+ try {
655
+ switch (schedule.kind) {
656
+ case "at": {
657
+ const atMs = new Date(schedule.at).getTime();
658
+ if (Number.isNaN(atMs)) return void 0;
659
+ return atMs > nowMs ? atMs : void 0;
660
+ }
661
+ case "every": {
662
+ const everyMs = Math.max(1, Math.floor(schedule.everyMs));
663
+ const anchorMs = schedule.anchorMs ?? nowMs;
664
+ const elapsed = Math.max(0, nowMs - anchorMs);
665
+ const steps = Math.max(
666
+ 1,
667
+ Math.floor((elapsed + everyMs - 1) / everyMs)
668
+ );
669
+ return anchorMs + steps * everyMs;
670
+ }
671
+ case "cron": {
672
+ const interval = parseExpression(schedule.expr, {
673
+ tz: schedule.tz ?? this.timezone
674
+ });
675
+ const next = interval.next();
676
+ return next ? next.getTime() : void 0;
677
+ }
678
+ }
679
+ } catch {
680
+ return void 0;
681
+ }
682
+ }
683
+ computeJobNextRunAtMs(job, nowMs) {
684
+ if (!job.enabled) return void 0;
685
+ if (job.schedule.kind === "every") {
686
+ const lastRunAtMs = job.state.lastRunAtMs;
687
+ if (typeof lastRunAtMs === "number" && Number.isFinite(lastRunAtMs)) {
688
+ const nextFromLastRun = Math.floor(lastRunAtMs) + job.schedule.everyMs;
689
+ if (nextFromLastRun > nowMs) {
690
+ return nextFromLastRun;
691
+ }
692
+ }
693
+ }
694
+ if (job.schedule.kind === "at") {
695
+ if (job.state.lastRunStatus === "ok") {
696
+ return void 0;
697
+ }
698
+ }
699
+ return this.computeNextRunAtMs(job.schedule, nowMs);
700
+ }
701
+ isRunnableJob(job, nowMs) {
702
+ if (!job.enabled) return false;
703
+ if (typeof job.state.runningAtMs === "number") {
704
+ if (nowMs - job.state.runningAtMs > STUCK_RUN_MS) {
705
+ return true;
706
+ }
707
+ return false;
708
+ }
709
+ const next = job.state.nextRunAtMs;
710
+ return typeof next === "number" && nowMs >= next;
711
+ }
712
+ findDueJobs(nowMs) {
713
+ const jobs = this.store.getAllJobsSync();
714
+ return jobs.filter((job) => this.isRunnableJob(job, nowMs));
715
+ }
716
+ nextWakeAtMs() {
717
+ const jobs = this.store.getAllJobsSync();
718
+ let min;
719
+ for (const job of jobs) {
720
+ if (!job.enabled) continue;
721
+ if (job.state.runningAtMs !== void 0) continue;
722
+ const next = job.state.nextRunAtMs;
723
+ if (typeof next !== "number") continue;
724
+ if (min === void 0 || next < min) {
725
+ min = next;
726
+ }
727
+ }
728
+ return min;
729
+ }
730
+ armTimer() {
731
+ this.stopTimer();
732
+ if (!this.enabled) return;
733
+ const nextAt = this.nextWakeAtMs();
734
+ if (!nextAt) return;
735
+ const now = Date.now();
736
+ const delay = Math.max(0, nextAt - now);
737
+ const clampedDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
738
+ this.state.timer = setTimeout(() => {
739
+ void this.onTimer();
740
+ }, clampedDelay);
741
+ }
742
+ stopTimer() {
743
+ if (this.state.timer) {
744
+ clearTimeout(this.state.timer);
745
+ this.state.timer = null;
746
+ }
747
+ }
748
+ async onTimer() {
749
+ if (this.state.running) {
750
+ return;
751
+ }
752
+ this.state.running = true;
753
+ this.stopTimer();
754
+ try {
755
+ const dueJobs = await this.locked(async () => {
756
+ const now = Date.now();
757
+ const due = this.findDueJobs(now);
758
+ if (due.length === 0) {
759
+ this.recomputeNextRunsForMaintenance();
760
+ return [];
761
+ }
762
+ for (const job of due) {
763
+ job.state.runningAtMs = now;
764
+ job.state.lastError = void 0;
765
+ }
766
+ this.store.saveAllJobsSync();
767
+ return due;
768
+ });
769
+ for (const job of dueJobs) {
770
+ await this.executeJob(job);
771
+ }
772
+ } finally {
773
+ this.state.running = false;
774
+ this.armTimer();
775
+ }
776
+ }
777
+ recomputeNextRunsForMaintenance() {
778
+ const jobs = this.store.getAllJobsSync();
779
+ const now = Date.now();
780
+ let changed = false;
781
+ for (const job of jobs) {
782
+ if (!job.enabled) {
783
+ if (job.state.nextRunAtMs !== void 0) {
784
+ job.state.nextRunAtMs = void 0;
785
+ changed = true;
786
+ }
787
+ if (job.state.runningAtMs !== void 0) {
788
+ job.state.runningAtMs = void 0;
789
+ changed = true;
790
+ }
791
+ continue;
792
+ }
793
+ const runningAt = job.state.runningAtMs;
794
+ if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
795
+ job.state.runningAtMs = void 0;
796
+ changed = true;
797
+ }
798
+ if (job.state.nextRunAtMs === void 0) {
799
+ job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
800
+ changed = true;
801
+ }
802
+ }
803
+ if (changed) {
804
+ this.store.saveAllJobsSync();
805
+ }
806
+ return changed;
807
+ }
808
+ applyJobResult(job, result) {
809
+ job.state.runningAtMs = void 0;
810
+ job.state.lastRunAtMs = result.startedAt;
811
+ job.state.lastRunStatus = result.status;
812
+ job.state.lastDurationMs = result.endedAt - result.startedAt;
813
+ job.state.lastError = result.error;
814
+ if (result.status === "error") {
815
+ job.state.consecutiveErrors = (job.state.consecutiveErrors ?? 0) + 1;
816
+ } else {
817
+ job.state.consecutiveErrors = 0;
818
+ }
819
+ const shouldDelete = job.schedule.kind === "at" && job.deleteAfterRun === true && result.status === "ok";
820
+ if (!shouldDelete) {
821
+ if (job.schedule.kind === "at") {
822
+ job.enabled = false;
823
+ job.state.nextRunAtMs = void 0;
824
+ } else if (result.status === "error" && job.enabled) {
825
+ const backoff = errorBackoffMs(job.state.consecutiveErrors ?? 1);
826
+ const normalNext = this.computeJobNextRunAtMs(job, result.endedAt);
827
+ const backoffNext = result.endedAt + backoff;
828
+ job.state.nextRunAtMs = normalNext ? Math.max(normalNext, backoffNext) : backoffNext;
829
+ } else if (job.enabled) {
830
+ const naturalNext = this.computeJobNextRunAtMs(job, result.endedAt);
831
+ if (job.schedule.kind === "cron") {
832
+ const minNext = result.endedAt + MIN_REFIRE_GAP_MS;
833
+ job.state.nextRunAtMs = naturalNext ? Math.max(naturalNext, minNext) : minNext;
834
+ } else {
835
+ job.state.nextRunAtMs = naturalNext;
836
+ }
837
+ }
838
+ }
839
+ return shouldDelete;
840
+ }
841
+ async executeJob(job) {
842
+ const startedAt = Date.now();
843
+ this.emit({ jobId: job.id, action: "started", runAtMs: startedAt });
844
+ let result;
845
+ try {
846
+ if (job.sessionTarget === "main") {
847
+ result = await this.executor.runSystemEvent(job);
848
+ } else {
849
+ result = await this.executor.runAgentTurn(job);
850
+ }
851
+ } catch (err) {
852
+ result = {
853
+ status: "error",
854
+ error: err instanceof Error ? err.message : String(err)
855
+ };
856
+ }
857
+ const endedAt = Date.now();
858
+ if (job.delivery?.mode === "announce" && result.summary && job.delivery.channel && job.delivery.to) {
859
+ const deliveryResult = await this.dispatcher.dispatch({
860
+ channel: job.delivery.channel,
861
+ to: job.delivery.to,
862
+ message: result.summary
863
+ });
864
+ result.delivered = deliveryResult.delivered;
865
+ }
866
+ await this.locked(async () => {
867
+ const currentJob = this.store.getJobSync(job.id);
868
+ if (!currentJob) return;
869
+ const shouldDelete = this.applyJobResult(currentJob, {
870
+ status: result.status,
871
+ error: result.error,
872
+ delivered: result.delivered,
873
+ startedAt,
874
+ endedAt
875
+ });
876
+ this.emit({
877
+ jobId: currentJob.id,
878
+ action: "finished",
879
+ status: result.status,
880
+ error: result.error,
881
+ delivered: result.delivered,
882
+ runAtMs: startedAt,
883
+ durationMs: currentJob.state.lastDurationMs,
884
+ nextRunAtMs: currentJob.state.nextRunAtMs
885
+ });
886
+ if (shouldDelete) {
887
+ this.store.deleteJobSync(currentJob.id);
888
+ this.emit({ jobId: currentJob.id, action: "removed" });
889
+ } else {
890
+ this.store.saveJobSync(currentJob);
891
+ }
892
+ });
893
+ }
894
+ async start() {
895
+ if (!this.enabled) {
896
+ console.log("[scheduler] disabled");
897
+ return;
898
+ }
899
+ await this.locked(async () => {
900
+ const jobs = this.store.getAllJobsSync();
901
+ for (const job of jobs) {
902
+ if (typeof job.state.runningAtMs === "number") {
903
+ job.state.runningAtMs = void 0;
904
+ }
905
+ }
906
+ this.store.saveAllJobsSync();
907
+ this.recomputeNextRunsForMaintenance();
908
+ });
909
+ this.armTimer();
910
+ console.log("[scheduler] started");
911
+ }
912
+ async stop() {
913
+ this.stopTimer();
914
+ this.state.running = false;
915
+ await this.store.close();
916
+ }
917
+ async status() {
918
+ return this.locked(async () => {
919
+ const jobs = this.store.getAllJobsSync();
920
+ return {
921
+ enabled: this.enabled,
922
+ jobs: jobs.length,
923
+ nextWakeAtMs: this.nextWakeAtMs() ?? null
924
+ };
925
+ });
926
+ }
927
+ async add(input) {
928
+ return this.locked(async () => {
929
+ const now = Date.now();
930
+ const id = input.id ?? randomUUID();
931
+ const job = {
932
+ id,
933
+ agentId: input.agentId,
934
+ sessionKey: input.sessionKey,
935
+ name: input.name,
936
+ description: input.description,
937
+ enabled: input.enabled ?? true,
938
+ deleteAfterRun: input.deleteAfterRun,
939
+ createdAtMs: now,
940
+ updatedAtMs: now,
941
+ schedule: input.schedule,
942
+ sessionTarget: input.sessionTarget ?? "isolated",
943
+ wakeMode: input.wakeMode ?? "now",
944
+ payload: input.payload,
945
+ delivery: input.delivery,
946
+ state: {
947
+ ...input.state,
948
+ nextRunAtMs: void 0
949
+ }
950
+ };
951
+ job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
952
+ this.store.saveJobSync(job);
953
+ this.armTimer();
954
+ this.emit({
955
+ jobId: job.id,
956
+ action: "added",
957
+ nextRunAtMs: job.state.nextRunAtMs
958
+ });
959
+ return job;
960
+ });
961
+ }
962
+ async get(id) {
963
+ return this.store.getJobSync(id);
964
+ }
965
+ async list(opts) {
966
+ const jobs = this.store.getAllJobsSync();
967
+ const includeDisabled = opts?.includeDisabled === true;
968
+ const filtered = jobs.filter((j) => includeDisabled || j.enabled);
969
+ return filtered.sort(
970
+ (a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)
971
+ );
972
+ }
973
+ async listPage(opts) {
974
+ const jobs = this.store.getAllJobsSync();
975
+ const includeDisabled = opts?.enabled === "all" || opts?.enabled === "disabled";
976
+ const includeEnabled = opts?.enabled !== "disabled";
977
+ const query = opts?.query?.trim().toLowerCase() ?? "";
978
+ const filtered = jobs.filter((job) => {
979
+ if (!includeDisabled && !job.enabled) return false;
980
+ if (!includeEnabled && job.enabled) return false;
981
+ if (query) {
982
+ const haystack = [job.name, job.description ?? ""].join(" ").toLowerCase();
983
+ return haystack.includes(query);
984
+ }
985
+ return true;
986
+ });
987
+ const sortBy = opts?.sortBy ?? "nextRunAtMs";
988
+ const sortDir = opts?.sortDir ?? "asc";
989
+ const dir = sortDir === "desc" ? -1 : 1;
990
+ filtered.sort((a, b) => {
991
+ let cmp = 0;
992
+ if (sortBy === "name") {
993
+ cmp = a.name.localeCompare(b.name);
994
+ } else if (sortBy === "updatedAtMs") {
995
+ cmp = a.updatedAtMs - b.updatedAtMs;
996
+ } else {
997
+ const aNext = a.state.nextRunAtMs;
998
+ const bNext = b.state.nextRunAtMs;
999
+ if (typeof aNext === "number" && typeof bNext === "number") {
1000
+ cmp = aNext - bNext;
1001
+ } else if (typeof aNext === "number") {
1002
+ cmp = -1;
1003
+ } else if (typeof bNext === "number") {
1004
+ cmp = 1;
1005
+ }
1006
+ }
1007
+ return cmp * dir;
1008
+ });
1009
+ const total = filtered.length;
1010
+ const offset = Math.max(0, Math.min(total, opts?.offset ?? 0));
1011
+ const limit = Math.max(1, Math.min(200, opts?.limit ?? 50));
1012
+ const items = filtered.slice(offset, offset + limit);
1013
+ const nextOffset = offset + items.length;
1014
+ return {
1015
+ items,
1016
+ total,
1017
+ offset,
1018
+ limit,
1019
+ hasMore: nextOffset < total,
1020
+ nextOffset: nextOffset < total ? nextOffset : null
1021
+ };
1022
+ }
1023
+ async update(id, patch) {
1024
+ return this.locked(async () => {
1025
+ const job = this.store.getJobSync(id);
1026
+ if (!job) {
1027
+ throw new Error(`Job not found: ${id}`);
1028
+ }
1029
+ if (patch.name !== void 0) job.name = patch.name;
1030
+ if (patch.description !== void 0) job.description = patch.description;
1031
+ if (patch.enabled !== void 0) job.enabled = patch.enabled;
1032
+ if (patch.deleteAfterRun !== void 0)
1033
+ job.deleteAfterRun = patch.deleteAfterRun;
1034
+ if (patch.schedule !== void 0) job.schedule = patch.schedule;
1035
+ if (patch.sessionTarget !== void 0)
1036
+ job.sessionTarget = patch.sessionTarget;
1037
+ if (patch.wakeMode !== void 0) job.wakeMode = patch.wakeMode;
1038
+ if (patch.payload !== void 0) job.payload = patch.payload;
1039
+ if (patch.delivery !== void 0) job.delivery = patch.delivery;
1040
+ job.updatedAtMs = Date.now();
1041
+ if (job.enabled) {
1042
+ job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, job.updatedAtMs);
1043
+ } else {
1044
+ job.state.nextRunAtMs = void 0;
1045
+ job.state.runningAtMs = void 0;
1046
+ }
1047
+ this.store.saveJobSync(job);
1048
+ this.armTimer();
1049
+ this.emit({
1050
+ jobId: job.id,
1051
+ action: "updated",
1052
+ nextRunAtMs: job.state.nextRunAtMs
1053
+ });
1054
+ return job;
1055
+ });
1056
+ }
1057
+ async remove(id) {
1058
+ return this.locked(async () => {
1059
+ const existed = this.store.getJobSync(id) !== void 0;
1060
+ if (existed) {
1061
+ this.store.deleteJobSync(id);
1062
+ this.armTimer();
1063
+ this.emit({ jobId: id, action: "removed" });
1064
+ }
1065
+ return { ok: true, removed: existed };
1066
+ });
1067
+ }
1068
+ async run(id, mode) {
1069
+ const job = this.store.getJobSync(id);
1070
+ if (!job) {
1071
+ return { ok: false, ran: false };
1072
+ }
1073
+ if (typeof job.state.runningAtMs === "number") {
1074
+ return { ok: true, ran: false, reason: "already-running" };
1075
+ }
1076
+ const now = Date.now();
1077
+ const due = mode === "force" || job.enabled && typeof job.state.nextRunAtMs === "number" && now >= job.state.nextRunAtMs;
1078
+ if (!due) {
1079
+ return { ok: true, ran: false, reason: "not-due" };
1080
+ }
1081
+ await this.executeJob(job);
1082
+ return { ok: true, ran: true };
1083
+ }
1084
+ };
1085
+ async function createSchedulerService(config) {
1086
+ await config.store.init();
1087
+ return new SchedulerService(config);
1088
+ }
1089
+ export {
1090
+ SchedulerService,
1091
+ SqliteSchedulerStore,
1092
+ createSchedulerService,
1093
+ createSchedulerTools,
1094
+ createSqliteStore
1095
+ };
1096
+ //# sourceMappingURL=index.js.map