@stonyx/cron 0.2.1-alpha.2 → 0.2.1-alpha.21

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/normalize.js DELETED
@@ -1,163 +0,0 @@
1
- /**
2
- * Input normalization for AI-generated job definitions.
3
- * Handles imperfect JSON from AI models: wrong casing, missing fields,
4
- * flat-param recovery, type coercion.
5
- */
6
-
7
- /**
8
- * Normalize a schedule object. Infers kind from fields if missing.
9
- */
10
- export function normalizeSchedule(raw) {
11
- if (!raw || typeof raw !== 'object') return raw;
12
-
13
- const schedule = { ...raw };
14
-
15
- // Infer kind from fields if missing
16
- if (!schedule.kind) {
17
- if (schedule.at || schedule.atMs) schedule.kind = 'at';
18
- else if (schedule.everyMs) schedule.kind = 'every';
19
- else if (schedule.expr) schedule.kind = 'cron';
20
- }
21
-
22
- // Case normalization
23
- if (typeof schedule.kind === 'string') {
24
- schedule.kind = schedule.kind.toLowerCase();
25
- }
26
-
27
- // Legacy: atMs (number) → at (ISO string)
28
- if (schedule.atMs && !schedule.at) {
29
- schedule.at = new Date(schedule.atMs).toISOString();
30
- delete schedule.atMs;
31
- }
32
-
33
- // Coerce string everyMs to number
34
- if (typeof schedule.everyMs === 'string') {
35
- schedule.everyMs = Number(schedule.everyMs);
36
- }
37
-
38
- return schedule;
39
- }
40
-
41
- /**
42
- * Normalize a payload object. Infers kind from fields if missing.
43
- */
44
- export function normalizePayload(raw) {
45
- if (!raw || typeof raw !== 'object') return raw;
46
-
47
- const payload = { ...raw };
48
-
49
- // Infer kind from fields
50
- if (!payload.kind) {
51
- if (payload.message) payload.kind = 'agentTurn';
52
- else if (payload.text) payload.kind = 'systemEvent';
53
- }
54
-
55
- // Case normalization
56
- if (typeof payload.kind === 'string') {
57
- const lower = payload.kind.toLowerCase();
58
- if (lower === 'agentturn') payload.kind = 'agentTurn';
59
- else if (lower === 'systemevent') payload.kind = 'systemEvent';
60
- }
61
-
62
- return payload;
63
- }
64
-
65
- /**
66
- * Recover a job object from flat parameters.
67
- * AI models sometimes flatten nested fields to the top level.
68
- */
69
- const JOB_KEYS = new Set([
70
- 'name', 'description', 'schedule', 'sessionTarget', 'payload',
71
- 'delivery', 'enabled', 'deleteAfterRun', 'wakeMode',
72
- ]);
73
-
74
- export function recoverFlatParams(params) {
75
- if (params.job && typeof params.job === 'object' && Object.keys(params.job).length > 0) {
76
- return params.job;
77
- }
78
-
79
- const synthetic = {};
80
- for (const key of Object.keys(params)) {
81
- if (JOB_KEYS.has(key)) {
82
- synthetic[key] = params[key];
83
- }
84
- }
85
-
86
- // message/text are not JOB_KEYS but need to be recovered for payload wrapping
87
- const message = params.message;
88
- const text = params.text;
89
-
90
- if (synthetic.schedule || synthetic.payload || message || text) {
91
- // If message/text are at top level, wrap into payload
92
- if (!synthetic.payload) {
93
- if (message) {
94
- synthetic.payload = { kind: 'agentTurn', message };
95
- } else if (text) {
96
- synthetic.payload = { kind: 'systemEvent', text };
97
- }
98
- }
99
- return synthetic;
100
- }
101
-
102
- return params;
103
- }
104
-
105
- /**
106
- * Normalize a complete job input for creation.
107
- * Applies all normalization: schedule, payload, defaults.
108
- */
109
- export function normalizeJobInput(raw) {
110
- const job = { ...raw };
111
-
112
- if (job.schedule) {
113
- job.schedule = normalizeSchedule(job.schedule);
114
- }
115
-
116
- if (job.payload) {
117
- job.payload = normalizePayload(job.payload);
118
- }
119
-
120
- // Default: enabled
121
- if (job.enabled === undefined) job.enabled = true;
122
-
123
- // Default: wakeMode
124
- if (!job.wakeMode) job.wakeMode = 'now';
125
-
126
- // Default: sessionTarget inferred from payload
127
- if (!job.sessionTarget && job.payload) {
128
- job.sessionTarget = job.payload.kind === 'systemEvent' ? 'main' : 'isolated';
129
- }
130
-
131
- // Default: deleteAfterRun for one-shot
132
- if (job.deleteAfterRun === undefined && job.schedule?.kind === 'at') {
133
- job.deleteAfterRun = true;
134
- }
135
-
136
- // Default: delivery for isolated agentTurn
137
- if (!job.delivery && job.sessionTarget === 'isolated' && job.payload?.kind === 'agentTurn') {
138
- job.delivery = { mode: 'announce' };
139
- }
140
-
141
- // Auto-generate name if missing
142
- if (!job.name) {
143
- job.name = inferName(job);
144
- }
145
-
146
- return job;
147
- }
148
-
149
- /**
150
- * Infer a job name from schedule and payload.
151
- */
152
- function inferName(job) {
153
- const parts = [];
154
-
155
- if (job.schedule?.kind === 'at') parts.push('One-shot');
156
- else if (job.schedule?.kind === 'every') parts.push('Recurring');
157
- else if (job.schedule?.kind === 'cron') parts.push('Scheduled');
158
-
159
- if (job.payload?.kind === 'agentTurn') parts.push('agent task');
160
- else if (job.payload?.kind === 'systemEvent') parts.push('system event');
161
-
162
- return parts.join(' ') || 'Unnamed job';
163
- }
package/src/run-log.js DELETED
@@ -1,79 +0,0 @@
1
- /**
2
- * In-memory run log for job execution history.
3
- * Stores recent execution results per job with auto-pruning.
4
- *
5
- * Persistence (via ORM) is added in PR 2.
6
- */
7
-
8
- const DEFAULT_MAX_ENTRIES_PER_JOB = 100;
9
-
10
- export default class RunLog {
11
- constructor(maxEntriesPerJob = DEFAULT_MAX_ENTRIES_PER_JOB) {
12
- this.maxEntries = maxEntriesPerJob;
13
- this.entries = new Map(); // jobId → RunLogEntry[]
14
- }
15
-
16
- /**
17
- * Record a job execution result.
18
- *
19
- * @param {object} entry
20
- * @param {string} entry.jobId
21
- * @param {"ok"|"error"|"skipped"} entry.status
22
- * @param {string} [entry.error]
23
- * @param {string} [entry.summary]
24
- * @param {number} [entry.runAtMs]
25
- * @param {number} [entry.durationMs]
26
- * @param {number} [entry.nextRunAtMs]
27
- */
28
- record(entry) {
29
- const log = {
30
- ts: Date.now(),
31
- jobId: entry.jobId,
32
- status: entry.status,
33
- error: entry.error,
34
- summary: entry.summary,
35
- runAtMs: entry.runAtMs,
36
- durationMs: entry.durationMs,
37
- nextRunAtMs: entry.nextRunAtMs,
38
- };
39
-
40
- if (!this.entries.has(entry.jobId)) {
41
- this.entries.set(entry.jobId, []);
42
- }
43
-
44
- const logs = this.entries.get(entry.jobId);
45
- logs.push(log);
46
-
47
- // Auto-prune
48
- if (logs.length > this.maxEntries) {
49
- logs.splice(0, logs.length - this.maxEntries);
50
- }
51
- }
52
-
53
- /**
54
- * Get run history for a job.
55
- *
56
- * @param {string} jobId
57
- * @param {number} [limit=20]
58
- * @returns {object[]} Most recent entries, newest first
59
- */
60
- get(jobId, limit = 20) {
61
- const logs = this.entries.get(jobId);
62
- if (!logs) return [];
63
- return logs.slice(-limit).reverse();
64
- }
65
-
66
- /**
67
- * Remove all entries for a job.
68
- */
69
- removeJob(jobId) {
70
- this.entries.delete(jobId);
71
- }
72
-
73
- /**
74
- * Clear all entries.
75
- */
76
- clear() {
77
- this.entries.clear();
78
- }
79
- }
package/src/schedule.js DELETED
@@ -1,81 +0,0 @@
1
- /**
2
- * Schedule types and next-run computation.
3
- *
4
- * Three schedule kinds:
5
- * - "at": One-shot at an absolute ISO-8601 timestamp
6
- * - "every": Recurring interval in milliseconds
7
- * - "cron": 5-field cron expression with optional timezone
8
- */
9
- import { nextOccurrence, validateCronExpression } from './cron-parser.js';
10
-
11
- /**
12
- * Compute the next run time for a schedule.
13
- *
14
- * @param {object} schedule - Schedule definition
15
- * @param {string} schedule.kind - "at" | "every" | "cron"
16
- * @param {number} nowMs - Current time in milliseconds
17
- * @returns {number|undefined} Next run time in ms, or undefined if no future occurrence
18
- */
19
- export function computeNextRunAtMs(schedule, nowMs) {
20
- if (schedule.kind === 'at') {
21
- const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
22
- if (!Number.isFinite(atMs)) return undefined;
23
- return atMs > nowMs ? atMs : undefined;
24
- }
25
-
26
- if (schedule.kind === 'every') {
27
- const everyMs = Math.max(1, Math.floor(schedule.everyMs));
28
- const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
29
-
30
- if (nowMs < anchor) return anchor;
31
-
32
- const elapsed = nowMs - anchor;
33
- const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
34
- return anchor + steps * everyMs;
35
- }
36
-
37
- if (schedule.kind === 'cron') {
38
- const tz = schedule.tz?.trim() || undefined;
39
- // Round nowMs down to the current second to avoid sub-second drift
40
- const nowSecondMs = Math.floor(nowMs / 1000) * 1000;
41
- return nextOccurrence(schedule.expr.trim(), nowSecondMs, tz);
42
- }
43
-
44
- throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
45
- }
46
-
47
- /**
48
- * Validate a schedule definition.
49
- * @param {object} schedule
50
- * @throws {Error} if the schedule is invalid
51
- */
52
- export function validateSchedule(schedule) {
53
- if (!schedule || typeof schedule !== 'object') {
54
- throw new Error('Schedule must be an object');
55
- }
56
-
57
- if (schedule.kind === 'at') {
58
- const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
59
- if (!Number.isFinite(atMs)) {
60
- throw new Error(`Invalid "at" timestamp: "${schedule.at}"`);
61
- }
62
- return;
63
- }
64
-
65
- if (schedule.kind === 'every') {
66
- if (typeof schedule.everyMs !== 'number' || schedule.everyMs < 1) {
67
- throw new Error(`"every" schedule requires everyMs >= 1, got: ${schedule.everyMs}`);
68
- }
69
- return;
70
- }
71
-
72
- if (schedule.kind === 'cron') {
73
- if (typeof schedule.expr !== 'string' || !schedule.expr.trim()) {
74
- throw new Error('"cron" schedule requires a non-empty expr string');
75
- }
76
- validateCronExpression(schedule.expr.trim());
77
- return;
78
- }
79
-
80
- throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
81
- }
package/src/service.js DELETED
@@ -1,303 +0,0 @@
1
- /**
2
- * CronService — the main API for advanced job scheduling.
3
- *
4
- * Manages jobs in memory with a min-heap for efficient next-job lookup.
5
- * Supports pluggable store interface (memory-only by default, ORM in PR 2).
6
- * All state mutations are serialized via async locking.
7
- */
8
- import config from 'stonyx/config';
9
- import log from 'stonyx/log';
10
- import MinHeap from './min-heap.js';
11
- import { createJob, updateJob, markRunning, applyResult, isDue } from './job.js';
12
- import { computeNextRunAtMs } from './schedule.js';
13
- import { locked } from './locked.js';
14
- import { normalizeJobInput, recoverFlatParams } from './normalize.js';
15
- import RunLog from './run-log.js';
16
-
17
- const MAX_TIMER_DELAY_MS = 60_000;
18
-
19
- export default class CronService {
20
- constructor() {
21
- this.jobs = new Map(); // id → job
22
- this.heap = new MinHeap(); // ordered by nextRunAtMs
23
- this.timer = null;
24
- this.running = false;
25
- this.runLog = new RunLog();
26
- this.started = false;
27
-
28
- // Pluggable callbacks for consumers
29
- this.onJobDue = null; // async (job) => { status, error?, summary? }
30
- }
31
-
32
- // ── Lifecycle ──────────────────────────────────────────────
33
-
34
- /**
35
- * Start the service. Loads jobs from store (if any), arms timer.
36
- */
37
- async start(initialJobs) {
38
- if (this.started) return;
39
- this.started = true;
40
-
41
- if (initialJobs) {
42
- for (const job of initialJobs) {
43
- this.jobs.set(job.id, job);
44
- if (job.enabled && job.state.nextRunAtMs) {
45
- this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
46
- }
47
- }
48
- }
49
-
50
- this.armTimer();
51
- }
52
-
53
- /**
54
- * Stop the service. Clears timer.
55
- */
56
- stop() {
57
- this.started = false;
58
- clearTimeout(this.timer);
59
- this.timer = null;
60
- }
61
-
62
- // ── CRUD ───────────────────────────────────────────────────
63
-
64
- /**
65
- * Get service status.
66
- */
67
- status() {
68
- const peek = this.heap.peek();
69
- return {
70
- started: this.started,
71
- jobCount: this.jobs.size,
72
- nextWakeAtMs: peek ? peek.nextTrigger : undefined,
73
- };
74
- }
75
-
76
- /**
77
- * List jobs, optionally including disabled ones.
78
- */
79
- list(opts) {
80
- const includeDisabled = opts?.includeDisabled ?? false;
81
- const jobs = [...this.jobs.values()];
82
- const filtered = includeDisabled ? jobs : jobs.filter(j => j.enabled);
83
- return filtered.sort((a, b) => (a.state.nextRunAtMs ?? Infinity) - (b.state.nextRunAtMs ?? Infinity));
84
- }
85
-
86
- /**
87
- * Get a single job by ID.
88
- */
89
- get(id) {
90
- return this.jobs.get(id) || null;
91
- }
92
-
93
- /**
94
- * Add a new job. Input is normalized for AI compatibility.
95
- */
96
- async add(rawInput) {
97
- return locked(() => {
98
- const input = normalizeJobInput(recoverFlatParams(rawInput));
99
- const job = createJob(input);
100
- this.jobs.set(job.id, job);
101
-
102
- if (job.enabled && job.state.nextRunAtMs) {
103
- this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
104
- this.armTimer();
105
- }
106
-
107
- return job;
108
- });
109
- }
110
-
111
- /**
112
- * Update an existing job.
113
- */
114
- async update(id, patch) {
115
- return locked(() => {
116
- const job = this.jobs.get(id);
117
- if (!job) throw new Error(`Job not found: ${id}`);
118
-
119
- const oldNextRun = job.state.nextRunAtMs;
120
- updateJob(job, patch);
121
-
122
- // Update heap entry
123
- this.removeFromHeap(id);
124
- if (job.enabled && job.state.nextRunAtMs) {
125
- this.heap.push({ key: id, nextTrigger: job.state.nextRunAtMs });
126
- }
127
-
128
- if (job.state.nextRunAtMs !== oldNextRun) {
129
- this.armTimer();
130
- }
131
-
132
- return job;
133
- });
134
- }
135
-
136
- /**
137
- * Remove a job.
138
- */
139
- async remove(id) {
140
- return locked(() => {
141
- const job = this.jobs.get(id);
142
- if (!job) throw new Error(`Job not found: ${id}`);
143
-
144
- this.jobs.delete(id);
145
- this.removeFromHeap(id);
146
- this.runLog.removeJob(id);
147
- this.armTimer();
148
- });
149
- }
150
-
151
- /**
152
- * Manually trigger a job.
153
- *
154
- * @param {string} id - Job ID
155
- * @param {"due"|"force"} [mode="force"] - "due" only runs if the job is due, "force" runs regardless
156
- */
157
- async run(id, mode = 'force') {
158
- const job = this.jobs.get(id);
159
- if (!job) throw new Error(`Job not found: ${id}`);
160
-
161
- if (mode === 'due' && !isDue(job, Date.now())) {
162
- return { status: 'skipped', reason: 'not due' };
163
- }
164
-
165
- return this.executeJob(job);
166
- }
167
-
168
- /**
169
- * Get run history for a job.
170
- */
171
- runs(id, limit) {
172
- return this.runLog.get(id, limit);
173
- }
174
-
175
- // ── Timer Engine ──────────────────────────────────────��────
176
-
177
- armTimer() {
178
- clearTimeout(this.timer);
179
- if (!this.started) return;
180
-
181
- const peek = this.heap.peek();
182
- if (!peek) return;
183
-
184
- const delay = Math.min(Math.max(peek.nextTrigger - Date.now(), 0), MAX_TIMER_DELAY_MS);
185
- this.timer = setTimeout(() => this.onTimer(), delay);
186
- }
187
-
188
- async onTimer() {
189
- if (this.running) {
190
- // Already processing — re-arm at max delay to prevent scheduler death
191
- this.timer = setTimeout(() => this.onTimer(), MAX_TIMER_DELAY_MS);
192
- return;
193
- }
194
-
195
- this.running = true;
196
-
197
- try {
198
- await locked(async () => {
199
- const nowMs = Date.now();
200
- const dueJobs = this.findDueJobs(nowMs);
201
-
202
- for (const job of dueJobs) {
203
- markRunning(job);
204
- }
205
-
206
- for (const job of dueJobs) {
207
- await this.executeJob(job);
208
- }
209
- });
210
- } finally {
211
- this.running = false;
212
- this.armTimer();
213
- }
214
- }
215
-
216
- findDueJobs(nowMs) {
217
- const due = [];
218
-
219
- while (!this.heap.isEmpty()) {
220
- const peek = this.heap.peek();
221
- if (peek.nextTrigger > nowMs) break;
222
-
223
- this.heap.pop();
224
- const job = this.jobs.get(peek.key);
225
- if (job && isDue(job, nowMs)) {
226
- due.push(job);
227
- }
228
- }
229
-
230
- return due;
231
- }
232
-
233
- async executeJob(job) {
234
- const startMs = Date.now();
235
- let status = 'ok';
236
- let error;
237
- let summary;
238
-
239
- try {
240
- if (this.onJobDue) {
241
- const result = await this.onJobDue(job);
242
- if (result) {
243
- status = result.status || 'ok';
244
- error = result.error;
245
- summary = result.summary;
246
- }
247
- }
248
- } catch (err) {
249
- status = 'error';
250
- error = err?.message || String(err);
251
- this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
252
- }
253
-
254
- const durationMs = Date.now() - startMs;
255
-
256
- applyResult(job, status, error, durationMs);
257
-
258
- // Log the run
259
- this.runLog.record({
260
- jobId: job.id,
261
- status,
262
- error,
263
- summary,
264
- runAtMs: startMs,
265
- durationMs,
266
- nextRunAtMs: job.state.nextRunAtMs,
267
- });
268
-
269
- // Handle one-shot auto-delete
270
- if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
271
- this.jobs.delete(job.id);
272
- this.runLog.removeJob(job.id);
273
- return { status, summary, deleted: true };
274
- }
275
-
276
- // Re-insert into heap if still active
277
- if (job.enabled && job.state.nextRunAtMs) {
278
- this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
279
- }
280
-
281
- return { status, error, summary, durationMs };
282
- }
283
-
284
- // ── Helpers ────────────────────────────────────────────────
285
-
286
- removeFromHeap(id) {
287
- // MinHeap doesn't support remove-by-key efficiently,
288
- // so we rebuild. Fine for typical job counts (< 1000).
289
- const remaining = [];
290
- while (!this.heap.isEmpty()) {
291
- const item = this.heap.pop();
292
- if (item.key !== id) remaining.push(item);
293
- }
294
- for (const item of remaining) {
295
- this.heap.push(item);
296
- }
297
- }
298
-
299
- log(message) {
300
- if (!config.cron?.log) return;
301
- log.cron(`Cron — ${message}`);
302
- }
303
- }
@@ -1,9 +0,0 @@
1
- /**
2
- * commonJS Bootstrap loading - Stonyx must be loaded first, prior to the rest of the application
3
- */
4
- const { default:Stonyx } = require('stonyx');
5
- const { default:config } = require('./config/environment.js');
6
-
7
- new Stonyx(config, __dirname);
8
-
9
- module.exports = Stonyx;