@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 +71 -0
- package/package.json +2 -3
- package/src/cron.js +127 -15
- package/src/index.js +4 -3
- package/src/store.js +0 -52
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.
|
|
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 {
|
|
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(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 [
|
|
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 =
|
|
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
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
|
-
}
|