@vox-ai-app/scheduler 1.0.0 → 1.0.1

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 ADDED
@@ -0,0 +1,71 @@
1
+ # @vox-ai-app/scheduler
2
+
3
+ Cron-based job scheduler for Vox — schedule recurring agent runs, heartbeats, and timed tasks with timezone support.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @vox-ai-app/scheduler
9
+ ```
10
+
11
+ ## Exports
12
+
13
+ | Export | Contents |
14
+ | ---------------------------- | ------------------------------------- |
15
+ | `@vox-ai-app/scheduler` | All scheduler exports |
16
+ | `@vox-ai-app/scheduler/cron` | Job scheduling, cancellation, listing |
17
+
18
+ ## Usage
19
+
20
+ ```js
21
+ import { scheduleJob, cancelJob, listJobs, computeNextRun } from '@vox-ai-app/scheduler'
22
+
23
+ const job = scheduleJob(
24
+ 'daily-check',
25
+ { expr: '0 9 * * *', tz: 'America/New_York' },
26
+ (id, meta) => {
27
+ console.log(`Job ${id} fired at ${meta.firedAt}`)
28
+ }
29
+ )
30
+
31
+ console.log(listJobs())
32
+ cancelJob('daily-check')
33
+ ```
34
+
35
+ Schedule persistence is handled by the app layer via `@vox-ai-app/storage/schedules`.
36
+
37
+ ## API
38
+
39
+ ### Cron
40
+
41
+ | Function | Description |
42
+ | ---------------- | ----------------------------------------------- |
43
+ | `scheduleJob` | Schedule a cron job with handler callback |
44
+ | `cancelJob` | Cancel a job by ID |
45
+ | `cancelAllJobs` | Cancel all running jobs |
46
+ | `getJob` | Get job details by ID |
47
+ | `listJobs` | List all active jobs with next run times |
48
+ | `computeNextRun` | Compute the next run time for a cron expression |
49
+
50
+ `scheduleJob` options:
51
+
52
+ ```js
53
+ scheduleJob(
54
+ id,
55
+ {
56
+ expr: '0 9 * * 1-5',
57
+ tz: 'America/New_York',
58
+ runImmediately: false,
59
+ onError: (err) => {}
60
+ },
61
+ handler
62
+ )
63
+ ```
64
+
65
+ ## Dependencies
66
+
67
+ - [croner](https://github.com/hexagon/croner) ^9.0.0
68
+
69
+ ## License
70
+
71
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vox-ai-app/scheduler",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Cron-based job scheduler for Vox — schedule recurring agent runs, heartbeats, and timed tasks",
@@ -8,8 +8,7 @@
8
8
  "private": false,
9
9
  "exports": {
10
10
  ".": "./src/index.js",
11
- "./cron": "./src/cron.js",
12
- "./store": "./src/store.js"
11
+ "./cron": "./src/cron.js"
13
12
  },
14
13
  "publishConfig": {
15
14
  "access": "public",
package/src/cron.js CHANGED
@@ -1,26 +1,64 @@
1
1
  import { Cron } from 'croner'
2
2
 
3
3
  const jobs = new Map()
4
+ const CRON_EVAL_CACHE_MAX = 512
5
+ const cronEvalCache = new Map()
6
+ const MIN_REFIRE_GAP_MS = 2000
4
7
 
5
8
  function resolveTimezone(tz) {
6
9
  if (tz && typeof tz === 'string' && tz.trim()) return tz.trim()
7
- try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
10
+ try {
11
+ return Intl.DateTimeFormat().resolvedOptions().timeZone
12
+ } catch {
13
+ return 'UTC'
14
+ }
15
+ }
16
+
17
+ function getCachedCron(expr, timezone) {
18
+ const key = `${timezone}\0${expr}`
19
+ const cached = cronEvalCache.get(key)
20
+ if (cached) return cached
21
+ if (cronEvalCache.size >= CRON_EVAL_CACHE_MAX) {
22
+ const oldest = cronEvalCache.keys().next().value
23
+ cronEvalCache.delete(oldest)
24
+ }
25
+ const cron = new Cron(expr, { timezone, catch: false })
26
+ cronEvalCache.set(key, cron)
27
+ return cron
8
28
  }
9
29
 
10
30
  export function scheduleJob(id, config, handler) {
11
31
  if (jobs.has(id)) cancelJob(id)
12
32
 
13
- const { expr, tz, runImmediately } = config
33
+ const { expr, tz, runImmediately, timeoutMs } = config
14
34
  const timezone = resolveTimezone(tz)
35
+ let lastFireMs = 0
15
36
 
16
- const cron = new Cron(expr, {
17
- timezone,
18
- catch: (err) => {
19
- if (config.onError) config.onError(err)
37
+ const cron = new Cron(
38
+ expr,
39
+ {
40
+ timezone,
41
+ catch: (err) => {
42
+ if (config.onError) config.onError(err)
43
+ }
44
+ },
45
+ () => {
46
+ const now = Date.now()
47
+ if (now - lastFireMs < MIN_REFIRE_GAP_MS) return
48
+ lastFireMs = now
49
+
50
+ const context = { scheduledAt: now, expr, timezone }
51
+ if (typeof timeoutMs === 'number' && timeoutMs > 0) {
52
+ const ac = new AbortController()
53
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
54
+ Promise.resolve(handler(id, { ...context, signal: ac.signal })).finally(() =>
55
+ clearTimeout(timer)
56
+ )
57
+ } else {
58
+ handler(id, context)
59
+ }
20
60
  }
21
- }, () => {
22
- handler(id, { scheduledAt: Date.now(), expr, timezone })
23
- })
61
+ )
24
62
 
25
63
  const job = {
26
64
  id,
@@ -28,7 +66,10 @@ export function scheduleJob(id, config, handler) {
28
66
  expr,
29
67
  timezone,
30
68
  createdAt: Date.now(),
31
- handler
69
+ handler,
70
+ timeoutMs: timeoutMs || null,
71
+ lastFireMs: 0,
72
+ state: { runCount: 0, lastError: null, lastRunAtMs: null }
32
73
  }
33
74
 
34
75
  jobs.set(id, job)
@@ -49,7 +90,7 @@ export function cancelJob(id) {
49
90
  }
50
91
 
51
92
  export function cancelAllJobs() {
52
- for (const [id, job] of jobs) {
93
+ for (const [, job] of jobs) {
53
94
  job.cron.stop()
54
95
  }
55
96
  jobs.clear()
@@ -64,24 +105,95 @@ export function getJob(id) {
64
105
  timezone: job.timezone,
65
106
  createdAt: job.createdAt,
66
107
  nextRun: job.cron.nextRun()?.getTime() || null,
67
- running: job.cron.isBusy()
108
+ running: job.cron.isBusy(),
109
+ state: { ...job.state }
68
110
  }
69
111
  }
70
112
 
71
113
  export function listJobs() {
72
- return Array.from(jobs.values()).map(job => ({
114
+ return Array.from(jobs.values()).map((job) => ({
73
115
  id: job.id,
74
116
  expr: job.expr,
75
117
  timezone: job.timezone,
76
118
  createdAt: job.createdAt,
77
119
  nextRun: job.cron.nextRun()?.getTime() || null,
78
- running: job.cron.isBusy()
120
+ running: job.cron.isBusy(),
121
+ state: { ...job.state }
79
122
  }))
80
123
  }
81
124
 
82
125
  export function computeNextRun(expr, tz) {
83
126
  const timezone = resolveTimezone(tz)
84
- const cron = new Cron(expr, { timezone })
127
+ const cron = getCachedCron(expr, timezone)
85
128
  const next = cron.nextRun()
86
129
  return next ? next.getTime() : null
87
130
  }
131
+
132
+ export function parseAbsoluteTimeMs(input) {
133
+ const raw = typeof input === 'string' ? input.trim() : ''
134
+ if (!raw) return null
135
+ if (/^\d+$/.test(raw)) {
136
+ const n = Number(raw)
137
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null
138
+ }
139
+ const parsed = Date.parse(raw)
140
+ return Number.isFinite(parsed) ? parsed : null
141
+ }
142
+
143
+ export function scheduleAt(id, config, handler) {
144
+ if (jobs.has(id)) cancelJob(id)
145
+
146
+ const atMs = typeof config.at === 'number' ? config.at : parseAbsoluteTimeMs(config.at)
147
+ if (!atMs || atMs <= Date.now()) return null
148
+
149
+ const delay = atMs - Date.now()
150
+ const timer = setTimeout(() => {
151
+ handler(id, { scheduledAt: atMs, once: true })
152
+ jobs.delete(id)
153
+ }, delay)
154
+
155
+ const job = {
156
+ id,
157
+ timer,
158
+ atMs,
159
+ createdAt: Date.now(),
160
+ handler,
161
+ kind: 'at'
162
+ }
163
+ jobs.set(id, job)
164
+ return job
165
+ }
166
+
167
+ export function scheduleEvery(id, config, handler) {
168
+ if (jobs.has(id)) cancelJob(id)
169
+
170
+ const { intervalMs, timeoutMs } = config
171
+ if (!intervalMs || intervalMs < 1000) return null
172
+
173
+ const interval = setInterval(() => {
174
+ const context = { scheduledAt: Date.now(), intervalMs }
175
+ if (typeof timeoutMs === 'number' && timeoutMs > 0) {
176
+ const ac = new AbortController()
177
+ const t = setTimeout(() => ac.abort(), timeoutMs)
178
+ Promise.resolve(handler(id, { ...context, signal: ac.signal })).finally(() => clearTimeout(t))
179
+ } else {
180
+ handler(id, context)
181
+ }
182
+ }, intervalMs)
183
+
184
+ const job = {
185
+ id,
186
+ interval,
187
+ intervalMs,
188
+ createdAt: Date.now(),
189
+ handler,
190
+ kind: 'every'
191
+ }
192
+ jobs.set(id, job)
193
+
194
+ if (config.runImmediately) {
195
+ handler(id, { scheduledAt: Date.now(), intervalMs, immediate: true })
196
+ }
197
+
198
+ return job
199
+ }
package/src/index.js CHANGED
@@ -4,7 +4,8 @@ export {
4
4
  cancelAllJobs,
5
5
  getJob,
6
6
  listJobs,
7
- computeNextRun
7
+ computeNextRun,
8
+ parseAbsoluteTimeMs,
9
+ scheduleAt,
10
+ scheduleEvery
8
11
  } from './cron.js'
9
-
10
- export { createStore } from './store.js'
package/src/store.js DELETED
@@ -1,52 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
-
4
- const SCHEDULES_FILE = 'schedules.json'
5
-
6
- export function createStore(dataDir) {
7
- const filePath = path.join(dataDir, SCHEDULES_FILE)
8
-
9
- function readAll() {
10
- try {
11
- const raw = fs.readFileSync(filePath, 'utf8')
12
- return JSON.parse(raw)
13
- } catch {
14
- return []
15
- }
16
- }
17
-
18
- function writeAll(schedules) {
19
- fs.mkdirSync(dataDir, { recursive: true })
20
- fs.writeFileSync(filePath, JSON.stringify(schedules, null, 2), 'utf8')
21
- }
22
-
23
- function save(schedule) {
24
- const all = readAll()
25
- const idx = all.findIndex(s => s.id === schedule.id)
26
- if (idx >= 0) {
27
- all[idx] = { ...all[idx], ...schedule, updatedAt: Date.now() }
28
- } else {
29
- all.push({ ...schedule, createdAt: Date.now() })
30
- }
31
- writeAll(all)
32
- return schedule
33
- }
34
-
35
- function remove(id) {
36
- const all = readAll()
37
- const filtered = all.filter(s => s.id !== id)
38
- if (filtered.length === all.length) return false
39
- writeAll(filtered)
40
- return true
41
- }
42
-
43
- function get(id) {
44
- return readAll().find(s => s.id === id) || null
45
- }
46
-
47
- function list() {
48
- return readAll()
49
- }
50
-
51
- return { save, remove, get, list }
52
- }