@stonyx/cron 0.2.1-alpha.4 → 0.2.1-alpha.6
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 +4 -0
- package/dist/cron-parser.d.ts +30 -0
- package/dist/cron-parser.js +200 -0
- package/dist/job.d.ts +72 -0
- package/dist/job.js +172 -0
- package/dist/locked.d.ts +13 -0
- package/dist/locked.js +27 -0
- package/dist/main.d.ts +20 -0
- package/dist/main.js +101 -0
- package/dist/min-heap.d.ts +13 -0
- package/dist/min-heap.js +67 -0
- package/dist/normalize.d.ts +49 -0
- package/dist/normalize.js +148 -0
- package/dist/run-log.d.ts +44 -0
- package/dist/run-log.js +60 -0
- package/dist/schedule.d.ts +23 -0
- package/dist/schedule.js +65 -0
- package/dist/service.d.ts +85 -0
- package/dist/service.js +271 -0
- package/package.json +47 -15
- package/src/cron-parser.js +0 -246
- package/src/job.js +0 -200
- package/src/locked.js +0 -34
- package/src/main.js +0 -112
- package/src/min-heap.js +0 -73
- package/src/normalize.js +0 -163
- package/src/run-log.js +0 -79
- package/src/schedule.js +0 -81
- package/src/service.js +0 -303
package/src/job.js
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Job data model and state machine for the advanced scheduling system.
|
|
3
|
-
*/
|
|
4
|
-
import { computeNextRunAtMs, validateSchedule } from './schedule.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Error backoff table (milliseconds).
|
|
8
|
-
* Applied after consecutive errors to prevent hammering.
|
|
9
|
-
*/
|
|
10
|
-
const ERROR_BACKOFF_MS = [30_000, 60_000, 300_000, 900_000, 3_600_000];
|
|
11
|
-
|
|
12
|
-
export function errorBackoffMs(consecutiveErrors) {
|
|
13
|
-
if (consecutiveErrors < 1) return 0;
|
|
14
|
-
return ERROR_BACKOFF_MS[Math.min(consecutiveErrors - 1, ERROR_BACKOFF_MS.length - 1)];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Create a new job object from input.
|
|
19
|
-
*
|
|
20
|
-
* @param {object} input - Job creation input
|
|
21
|
-
* @param {string} input.name - Job name
|
|
22
|
-
* @param {object} input.schedule - Schedule definition (at/every/cron)
|
|
23
|
-
* @param {object} input.payload - What to execute
|
|
24
|
-
* @param {string} [input.description]
|
|
25
|
-
* @param {boolean} [input.enabled=true]
|
|
26
|
-
* @param {boolean} [input.deleteAfterRun=false]
|
|
27
|
-
* @param {string} [input.sessionTarget="isolated"]
|
|
28
|
-
* @param {string} [input.wakeMode="now"]
|
|
29
|
-
* @param {object} [input.delivery]
|
|
30
|
-
* @returns {object} Complete job object with state
|
|
31
|
-
*/
|
|
32
|
-
export function createJob(input) {
|
|
33
|
-
validateSchedule(input.schedule);
|
|
34
|
-
|
|
35
|
-
const nowMs = Date.now();
|
|
36
|
-
const enabled = input.enabled !== false;
|
|
37
|
-
const deleteAfterRun = input.deleteAfterRun ?? (input.schedule.kind === 'at');
|
|
38
|
-
|
|
39
|
-
const job = {
|
|
40
|
-
id: crypto.randomUUID(),
|
|
41
|
-
name: input.name,
|
|
42
|
-
description: input.description || undefined,
|
|
43
|
-
enabled,
|
|
44
|
-
deleteAfterRun,
|
|
45
|
-
createdAtMs: nowMs,
|
|
46
|
-
updatedAtMs: nowMs,
|
|
47
|
-
schedule: { ...input.schedule },
|
|
48
|
-
sessionTarget: input.sessionTarget || 'isolated',
|
|
49
|
-
wakeMode: input.wakeMode || 'now',
|
|
50
|
-
payload: { ...input.payload },
|
|
51
|
-
delivery: input.delivery ? { ...input.delivery } : undefined,
|
|
52
|
-
state: {
|
|
53
|
-
nextRunAtMs: undefined,
|
|
54
|
-
runningAtMs: undefined,
|
|
55
|
-
lastRunAtMs: undefined,
|
|
56
|
-
lastStatus: undefined,
|
|
57
|
-
lastError: undefined,
|
|
58
|
-
lastDurationMs: undefined,
|
|
59
|
-
consecutiveErrors: 0,
|
|
60
|
-
scheduleErrorCount: 0,
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
// Compute initial next run
|
|
65
|
-
if (enabled) {
|
|
66
|
-
try {
|
|
67
|
-
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
68
|
-
} catch {
|
|
69
|
-
job.state.scheduleErrorCount = 1;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return job;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Apply an update patch to a job.
|
|
78
|
-
*
|
|
79
|
-
* @param {object} job - Existing job
|
|
80
|
-
* @param {object} patch - Fields to update
|
|
81
|
-
* @returns {object} Updated job (same reference, mutated)
|
|
82
|
-
*/
|
|
83
|
-
export function updateJob(job, patch) {
|
|
84
|
-
const nowMs = Date.now();
|
|
85
|
-
|
|
86
|
-
if (patch.name !== undefined) job.name = patch.name;
|
|
87
|
-
if (patch.description !== undefined) job.description = patch.description || undefined;
|
|
88
|
-
if (patch.deleteAfterRun !== undefined) job.deleteAfterRun = patch.deleteAfterRun;
|
|
89
|
-
if (patch.sessionTarget !== undefined) job.sessionTarget = patch.sessionTarget;
|
|
90
|
-
if (patch.wakeMode !== undefined) job.wakeMode = patch.wakeMode;
|
|
91
|
-
if (patch.payload !== undefined) job.payload = { ...patch.payload };
|
|
92
|
-
if (patch.delivery !== undefined) job.delivery = patch.delivery ? { ...patch.delivery } : undefined;
|
|
93
|
-
|
|
94
|
-
if (patch.schedule !== undefined) {
|
|
95
|
-
validateSchedule(patch.schedule);
|
|
96
|
-
job.schedule = { ...patch.schedule };
|
|
97
|
-
job.state.scheduleErrorCount = 0;
|
|
98
|
-
// Recompute next run
|
|
99
|
-
if (job.enabled) {
|
|
100
|
-
try {
|
|
101
|
-
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
102
|
-
} catch {
|
|
103
|
-
job.state.scheduleErrorCount = 1;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (patch.enabled !== undefined) {
|
|
109
|
-
job.enabled = patch.enabled;
|
|
110
|
-
if (job.enabled && !job.state.nextRunAtMs) {
|
|
111
|
-
try {
|
|
112
|
-
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
113
|
-
} catch {
|
|
114
|
-
job.state.scheduleErrorCount++;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
if (!job.enabled) {
|
|
118
|
-
job.state.nextRunAtMs = undefined;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
job.updatedAtMs = nowMs;
|
|
123
|
-
return job;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Mark a job as started (running).
|
|
128
|
-
*/
|
|
129
|
-
export function markRunning(job) {
|
|
130
|
-
job.state.runningAtMs = Date.now();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Apply the result of a job execution.
|
|
135
|
-
*
|
|
136
|
-
* @param {object} job - The job
|
|
137
|
-
* @param {"ok"|"error"|"skipped"} status - Execution result
|
|
138
|
-
* @param {string} [error] - Error message if status is "error"
|
|
139
|
-
* @param {number} [durationMs] - Execution duration
|
|
140
|
-
*/
|
|
141
|
-
export function applyResult(job, status, error, durationMs) {
|
|
142
|
-
const nowMs = Date.now();
|
|
143
|
-
|
|
144
|
-
job.state.lastRunAtMs = job.state.runningAtMs || nowMs;
|
|
145
|
-
job.state.runningAtMs = undefined;
|
|
146
|
-
job.state.lastStatus = status;
|
|
147
|
-
job.state.lastError = status === 'error' ? error : undefined;
|
|
148
|
-
job.state.lastDurationMs = durationMs;
|
|
149
|
-
|
|
150
|
-
if (status === 'error') {
|
|
151
|
-
job.state.consecutiveErrors = (job.state.consecutiveErrors || 0) + 1;
|
|
152
|
-
} else {
|
|
153
|
-
job.state.consecutiveErrors = 0;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// One-shot jobs: disable after any terminal status
|
|
157
|
-
if (job.schedule.kind === 'at') {
|
|
158
|
-
job.enabled = false;
|
|
159
|
-
job.state.nextRunAtMs = undefined;
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Recurring jobs: compute next run with backoff
|
|
164
|
-
if (job.enabled) {
|
|
165
|
-
try {
|
|
166
|
-
const normalNext = computeNextRunAtMs(job.schedule, nowMs);
|
|
167
|
-
if (normalNext === undefined) {
|
|
168
|
-
job.enabled = false;
|
|
169
|
-
job.state.nextRunAtMs = undefined;
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (status === 'error' && job.state.consecutiveErrors > 0) {
|
|
174
|
-
const backoff = errorBackoffMs(job.state.consecutiveErrors);
|
|
175
|
-
job.state.nextRunAtMs = Math.max(normalNext, nowMs + backoff);
|
|
176
|
-
} else {
|
|
177
|
-
job.state.nextRunAtMs = normalNext;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
job.state.scheduleErrorCount = 0;
|
|
181
|
-
} catch {
|
|
182
|
-
job.state.scheduleErrorCount = (job.state.scheduleErrorCount || 0) + 1;
|
|
183
|
-
// Auto-disable after 3 consecutive schedule computation errors
|
|
184
|
-
if (job.state.scheduleErrorCount >= 3) {
|
|
185
|
-
job.enabled = false;
|
|
186
|
-
job.state.nextRunAtMs = undefined;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Check if a job is due to run.
|
|
194
|
-
*/
|
|
195
|
-
export function isDue(job, nowMs) {
|
|
196
|
-
return job.enabled
|
|
197
|
-
&& !job.state.runningAtMs
|
|
198
|
-
&& job.state.nextRunAtMs !== undefined
|
|
199
|
-
&& job.state.nextRunAtMs <= nowMs;
|
|
200
|
-
}
|
package/src/locked.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Async locking mechanism to serialize state mutations.
|
|
3
|
-
* Prevents concurrent operations from corrupting job state.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
let chain = Promise.resolve();
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Execute a function with exclusive access to cron state.
|
|
10
|
-
* Operations queue behind each other — no concurrent mutations.
|
|
11
|
-
*
|
|
12
|
-
* @param {Function} fn - Async function to execute under lock
|
|
13
|
-
* @returns {Promise<*>} Result of fn
|
|
14
|
-
*/
|
|
15
|
-
export async function locked(fn) {
|
|
16
|
-
let resolve;
|
|
17
|
-
const prev = chain;
|
|
18
|
-
chain = new Promise(r => { resolve = r; });
|
|
19
|
-
|
|
20
|
-
await prev;
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
return await fn();
|
|
24
|
-
} finally {
|
|
25
|
-
resolve();
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Reset the lock chain. Only for testing.
|
|
31
|
-
*/
|
|
32
|
-
export function resetLock() {
|
|
33
|
-
chain = Promise.resolve();
|
|
34
|
-
}
|
package/src/main.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright 2025 Stone Costa
|
|
3
|
-
*
|
|
4
|
-
* Licensed under the Apache License, Version 2.0 (the 'License');
|
|
5
|
-
* you may not use this file except in compliance with the License.
|
|
6
|
-
* You may obtain a copy of the License at
|
|
7
|
-
*
|
|
8
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
*
|
|
10
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
* See the License for the specific language governing permissions and
|
|
14
|
-
* limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import config from 'stonyx/config';
|
|
18
|
-
import log from 'stonyx/log';
|
|
19
|
-
import { getTimestamp } from "@stonyx/utils/date";
|
|
20
|
-
import MinHeap from '@stonyx/cron/min-heap';
|
|
21
|
-
|
|
22
|
-
export default class Cron {
|
|
23
|
-
jobs = {};
|
|
24
|
-
heap = new MinHeap();
|
|
25
|
-
timer = null;
|
|
26
|
-
|
|
27
|
-
constructor() {
|
|
28
|
-
if (Cron.instance) return Cron.instance;
|
|
29
|
-
Cron.instance = this;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
scheduleNextRun() {
|
|
33
|
-
clearTimeout(this.timer);
|
|
34
|
-
|
|
35
|
-
const { heap } = this;
|
|
36
|
-
|
|
37
|
-
if (heap.isEmpty()) return;
|
|
38
|
-
|
|
39
|
-
const nextJob = heap.peek();
|
|
40
|
-
const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
|
|
41
|
-
|
|
42
|
-
this.timer = setTimeout(() => this.runDueJobs(), delay);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async runDueJobs() {
|
|
46
|
-
const now = getTimestamp();
|
|
47
|
-
const { heap } = this;
|
|
48
|
-
|
|
49
|
-
while (!heap.isEmpty() && heap.peek().nextTrigger <= now) {
|
|
50
|
-
const job = heap.pop();
|
|
51
|
-
|
|
52
|
-
if (config.debug) this.log('job has been triggered', job.key);
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
await job.callback();
|
|
56
|
-
} catch (err) {
|
|
57
|
-
log.error(`Cron job "${job.key}" failed:`, err);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
this.setNextTrigger(job);
|
|
61
|
-
heap.push(job);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
this.scheduleNextRun();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
register(key, callback, interval, runOnInit=false) {
|
|
68
|
-
const job = { callback, interval, key };
|
|
69
|
-
this.jobs[key] = job;
|
|
70
|
-
this.setNextTrigger(job);
|
|
71
|
-
this.heap.push(job);
|
|
72
|
-
|
|
73
|
-
if (config.debug) {
|
|
74
|
-
this.log(`job has been registered with interval: ${interval}`, key);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (runOnInit) {
|
|
78
|
-
try {
|
|
79
|
-
callback();
|
|
80
|
-
} catch (err) {
|
|
81
|
-
log.error(`Cron job "${key}" failed on init:`, err);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
this.scheduleNextRun();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
unregister(key) {
|
|
89
|
-
const { heap, jobs } = this;
|
|
90
|
-
const job = jobs[key];
|
|
91
|
-
|
|
92
|
-
if (!job) return;
|
|
93
|
-
|
|
94
|
-
delete jobs[key];
|
|
95
|
-
heap.remove(job);
|
|
96
|
-
|
|
97
|
-
if (config.debug) this.log('job has been unregistered', key);
|
|
98
|
-
|
|
99
|
-
this.scheduleNextRun();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
setNextTrigger(job) {
|
|
103
|
-
job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
log(text, key = null) {
|
|
107
|
-
if (!config.cron?.log) return;
|
|
108
|
-
|
|
109
|
-
const tag = key ? `Cron::${key}` : `Cron`;
|
|
110
|
-
log.cron(`${tag} - ${text}:`);
|
|
111
|
-
}
|
|
112
|
-
}
|
package/src/min-heap.js
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
export default class MinHeap {
|
|
2
|
-
constructor() {
|
|
3
|
-
this.items = [];
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
push(job) {
|
|
7
|
-
this.items.push(job);
|
|
8
|
-
this.bubbleUp();
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
pop() {
|
|
12
|
-
if (this.items.length === 1) return this.items.pop();
|
|
13
|
-
const top = this.items[0];
|
|
14
|
-
this.items[0] = this.items.pop();
|
|
15
|
-
this.bubbleDown();
|
|
16
|
-
return top;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
peek() {
|
|
20
|
-
return this.items[0];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
bubbleUp() {
|
|
24
|
-
let idx = this.items.length - 1;
|
|
25
|
-
while (idx > 0) {
|
|
26
|
-
const parentIdx = Math.floor((idx - 1) / 2);
|
|
27
|
-
if (this.items[idx].nextTrigger >= this.items[parentIdx].nextTrigger) break;
|
|
28
|
-
[this.items[idx], this.items[parentIdx]] = [this.items[parentIdx], this.items[idx]];
|
|
29
|
-
idx = parentIdx;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
bubbleDown() {
|
|
34
|
-
let idx = 0;
|
|
35
|
-
const length = this.items.length;
|
|
36
|
-
|
|
37
|
-
while (true) {
|
|
38
|
-
let leftIdx = 2 * idx + 1;
|
|
39
|
-
let rightIdx = 2 * idx + 2;
|
|
40
|
-
let swapIdx = null;
|
|
41
|
-
|
|
42
|
-
if (leftIdx < length && this.items[leftIdx].nextTrigger < this.items[idx].nextTrigger) {
|
|
43
|
-
swapIdx = leftIdx;
|
|
44
|
-
}
|
|
45
|
-
if (
|
|
46
|
-
rightIdx < length &&
|
|
47
|
-
this.items[rightIdx].nextTrigger < (
|
|
48
|
-
swapIdx === null ? this.items[idx].nextTrigger : this.items[leftIdx].nextTrigger
|
|
49
|
-
)
|
|
50
|
-
) {
|
|
51
|
-
swapIdx = rightIdx;
|
|
52
|
-
}
|
|
53
|
-
if (swapIdx === null) break;
|
|
54
|
-
[this.items[idx], this.items[swapIdx]] = [this.items[swapIdx], this.items[idx]];
|
|
55
|
-
idx = swapIdx;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
remove(job) {
|
|
60
|
-
const idx = this.items.indexOf(job);
|
|
61
|
-
if (idx === -1) return;
|
|
62
|
-
const end = this.items.pop();
|
|
63
|
-
if (idx < this.items.length) {
|
|
64
|
-
this.items[idx] = end;
|
|
65
|
-
this.bubbleUp();
|
|
66
|
-
this.bubbleDown();
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
isEmpty() {
|
|
71
|
-
return this.items.length === 0;
|
|
72
|
-
}
|
|
73
|
-
}
|
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
|
-
}
|