@stonyx/cron 0.2.1-alpha.2 → 0.2.1-alpha.20
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 +12 -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 +61 -0
- package/dist/main.js +238 -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 +53 -16
- package/.claude/architecture.md +0 -215
- package/.claude/extension-guide.md +0 -291
- package/.claude/improvements.md +0 -53
- package/.claude/project-structure.md +0 -139
- package/.claude/testing.md +0 -85
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/.gitignore +0 -16
- package/.npmignore +0 -7
- package/logs/error.log +0 -4
- package/pnpm-lock.yaml +0 -370
- 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/stonyx-bootstrap.cjs +0 -9
package/dist/main.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
import config from 'stonyx/config';
|
|
17
|
+
import log from 'stonyx/log';
|
|
18
|
+
import { getTimestamp } from '@stonyx/utils/date';
|
|
19
|
+
import MinHeap from './min-heap.js';
|
|
20
|
+
/**
|
|
21
|
+
* Floor for a job interval, in whole seconds.
|
|
22
|
+
*
|
|
23
|
+
* `runDueJobs` no longer awaits the callback, so `next.nextTrigger > now` is the
|
|
24
|
+
* drain loop's only exit condition *and* the loop has no suspension point left.
|
|
25
|
+
* An interval that fails to advance `nextTrigger` therefore spins the loop
|
|
26
|
+
* forever and blocks the event loop, rather than merely scheduling too often.
|
|
27
|
+
*/
|
|
28
|
+
const MIN_INTERVAL_SECONDS = 1;
|
|
29
|
+
/**
|
|
30
|
+
* Render an unknown thrown value as log text.
|
|
31
|
+
*
|
|
32
|
+
* `@stonyx/logs` reads a second argument as `logToFile`, not as a format
|
|
33
|
+
* argument, so `log.error(message, err)` discards the error entirely *and*
|
|
34
|
+
* forces a disk write on every failure. The error has to be interpolated into
|
|
35
|
+
* the message instead — the shape `CronService.executeJob` already uses.
|
|
36
|
+
*/
|
|
37
|
+
function describeError(err) {
|
|
38
|
+
if (err instanceof Error)
|
|
39
|
+
return err.stack ?? `${err.name}: ${err.message}`;
|
|
40
|
+
return String(err);
|
|
41
|
+
}
|
|
42
|
+
export default class Cron {
|
|
43
|
+
static instance;
|
|
44
|
+
jobs = {};
|
|
45
|
+
heap = new MinHeap();
|
|
46
|
+
timer = null;
|
|
47
|
+
constructor() {
|
|
48
|
+
if (Cron.instance)
|
|
49
|
+
return Cron.instance;
|
|
50
|
+
Cron.instance = this;
|
|
51
|
+
}
|
|
52
|
+
async init() {
|
|
53
|
+
// Self-register so log.cron works even when @stonyx/cron is in the
|
|
54
|
+
// consumer's `dependencies` (stonyx loader only merges devDependencies).
|
|
55
|
+
const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
|
|
56
|
+
log.defineType(logMethod, logColor);
|
|
57
|
+
}
|
|
58
|
+
scheduleNextRun() {
|
|
59
|
+
if (this.timer)
|
|
60
|
+
clearTimeout(this.timer);
|
|
61
|
+
const { heap } = this;
|
|
62
|
+
if (heap.isEmpty())
|
|
63
|
+
return;
|
|
64
|
+
const nextJob = heap.peek();
|
|
65
|
+
if (!nextJob)
|
|
66
|
+
return;
|
|
67
|
+
const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
|
|
68
|
+
this.timer = setTimeout(() => this.runDueJobs(), delay);
|
|
69
|
+
}
|
|
70
|
+
async runDueJobs() {
|
|
71
|
+
const now = getTimestamp();
|
|
72
|
+
const { heap } = this;
|
|
73
|
+
while (!heap.isEmpty()) {
|
|
74
|
+
const next = heap.peek();
|
|
75
|
+
if (!next || next.nextTrigger > now)
|
|
76
|
+
break;
|
|
77
|
+
const job = heap.pop();
|
|
78
|
+
if (config.debug)
|
|
79
|
+
this.log('job has been triggered', job.key);
|
|
80
|
+
// Reschedule *before* invoking. The callback's result is not used by this
|
|
81
|
+
// class (`runDueJobs` returns void), so awaiting it bought nothing and
|
|
82
|
+
// cost the scheduler: a callback that never settled left the job absent
|
|
83
|
+
// from the heap and stopped the timer from ever re-arming.
|
|
84
|
+
this.setNextTrigger(job);
|
|
85
|
+
heap.push(job);
|
|
86
|
+
this.safeInvoke(job);
|
|
87
|
+
}
|
|
88
|
+
this.scheduleNextRun();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The one safe way this class invokes a consumer callback.
|
|
92
|
+
*
|
|
93
|
+
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
94
|
+
* rejections alike, and skips the invocation entirely when the job's previous
|
|
95
|
+
* invocation has not settled yet (fire-and-forget would otherwise let a slow
|
|
96
|
+
* job stack invocations on itself).
|
|
97
|
+
*/
|
|
98
|
+
safeInvoke(job, runOnInit = false) {
|
|
99
|
+
const { key } = job;
|
|
100
|
+
const context = runOnInit ? 'failed on init:' : 'failed:';
|
|
101
|
+
// The in-flight guard lives on the job object, not in a module-level set
|
|
102
|
+
// keyed by string. That is what gives each invocation an identity: the only
|
|
103
|
+
// thing that ever clears the flag is the settle handler of the invocation
|
|
104
|
+
// that set it, and that handler closes over this exact job object. A stale
|
|
105
|
+
// handler therefore cannot release a *later* invocation's guard. It also
|
|
106
|
+
// matches the in-repo idiom one tier up (`job.state.runningAtMs`).
|
|
107
|
+
//
|
|
108
|
+
// `unregister` needs no explicit clear as a result: the flag is dropped with
|
|
109
|
+
// the job object, so a re-registered key gets a fresh object and runs
|
|
110
|
+
// immediately, while the abandoned invocation can only ever release itself.
|
|
111
|
+
if (job.runningAtMs !== undefined) {
|
|
112
|
+
// Bounded: one line per stuck run, not one per tick. A permanently hung
|
|
113
|
+
// job is re-pushed and re-skipped every interval forever, which at the
|
|
114
|
+
// 1s interval this class's own tests use is ~86k log lines a day, per job
|
|
115
|
+
// — a disk-fill and ingest-cost vector on any deployment capturing stdout.
|
|
116
|
+
if (!job.skipReported) {
|
|
117
|
+
job.skipReported = true;
|
|
118
|
+
const runningForSeconds = Math.max(0, Math.round((Date.now() - job.runningAtMs) / 1000));
|
|
119
|
+
this.report('warn', `Cron job ${JSON.stringify(key)} is still running after ${runningForSeconds}s; skipping this `
|
|
120
|
+
+ 'tick and any further ticks until it settles (this warning is not repeated for this run)');
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
job.runningAtMs = Date.now();
|
|
125
|
+
job.skipReported = false;
|
|
126
|
+
try {
|
|
127
|
+
const result = job.callback();
|
|
128
|
+
if (result && typeof result.then === 'function') {
|
|
129
|
+
Promise.resolve(result)
|
|
130
|
+
.catch((err) => {
|
|
131
|
+
// Braces matter: returning `report`'s value would put it back into
|
|
132
|
+
// the chain, and `.finally` passes a rejection straight through.
|
|
133
|
+
this.report('error', `Cron job ${JSON.stringify(key)} ${context} ${describeError(err)}`);
|
|
134
|
+
})
|
|
135
|
+
.finally(() => { this.release(job); })
|
|
136
|
+
// Backstop: a throw inside the error handler or the release must not
|
|
137
|
+
// re-create the unhandled rejection this helper exists to prevent.
|
|
138
|
+
.catch(() => { });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.release(job);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
this.release(job);
|
|
145
|
+
this.report('error', `Cron job ${JSON.stringify(key)} ${context} ${describeError(err)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Report a scheduler-level message without ever letting the logger's own
|
|
150
|
+
* failure reach the caller.
|
|
151
|
+
*
|
|
152
|
+
* `@stonyx/logs` convenience methods return a promise and write to disk
|
|
153
|
+
* through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
|
|
154
|
+
* log volume that promise rejects; an unobserved rejection raised from inside
|
|
155
|
+
* the handler that exists to prevent unhandled rejections would re-create
|
|
156
|
+
* exactly the defect this class was fixed for (measured: exit code 1).
|
|
157
|
+
*/
|
|
158
|
+
report(level, message) {
|
|
159
|
+
try {
|
|
160
|
+
const result = level === 'error' ? log.error(message) : log.warn(message);
|
|
161
|
+
void Promise.resolve(result).catch(() => { });
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Nowhere left to report to; the logger must never stop the scheduler.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Release a job's in-flight guard. Only ever called for the job it belongs to. */
|
|
168
|
+
release(job) {
|
|
169
|
+
job.runningAtMs = undefined;
|
|
170
|
+
job.skipReported = false;
|
|
171
|
+
}
|
|
172
|
+
register(key, callback, interval, runOnInit = false) {
|
|
173
|
+
const seconds = this.parseInterval(interval);
|
|
174
|
+
// Fail fast rather than clamp. An unparseable interval is a programming
|
|
175
|
+
// error with exactly one likely cause — a cron expression handed to the
|
|
176
|
+
// legacy class, which takes whole seconds — and clamping it would silently
|
|
177
|
+
// run a job intended for every 5 minutes once per second, hammering whatever
|
|
178
|
+
// the callback talks to. Throwing surfaces it at the call site, at boot,
|
|
179
|
+
// before anything is scheduled. A degenerate-but-parseable interval (`'0'`,
|
|
180
|
+
// `'-5'`) is a different case: it is interpretable as "as often as possible"
|
|
181
|
+
// and is clamped to the floor with one warning.
|
|
182
|
+
if (seconds === null) {
|
|
183
|
+
throw new TypeError(`Cron job ${JSON.stringify(key)} has an invalid interval ${JSON.stringify(interval)}: `
|
|
184
|
+
+ 'expected whole seconds (e.g. \'30\'). The legacy Cron class does not accept cron '
|
|
185
|
+
+ 'expressions — use CronService for those.');
|
|
186
|
+
}
|
|
187
|
+
if (parseInt(interval, 10) < MIN_INTERVAL_SECONDS) {
|
|
188
|
+
this.report('warn', `Cron job ${JSON.stringify(key)} interval ${JSON.stringify(interval)} is below the `
|
|
189
|
+
+ `${MIN_INTERVAL_SECONDS}s floor; clamping to ${MIN_INTERVAL_SECONDS}s`);
|
|
190
|
+
}
|
|
191
|
+
const job = { callback, interval, key, nextTrigger: 0 };
|
|
192
|
+
this.jobs[key] = job;
|
|
193
|
+
this.setNextTrigger(job);
|
|
194
|
+
this.heap.push(job);
|
|
195
|
+
if (config.debug) {
|
|
196
|
+
this.log(`job has been registered with interval: ${interval}`, key);
|
|
197
|
+
}
|
|
198
|
+
if (runOnInit)
|
|
199
|
+
this.safeInvoke(job, true);
|
|
200
|
+
this.scheduleNextRun();
|
|
201
|
+
}
|
|
202
|
+
unregister(key) {
|
|
203
|
+
const { heap, jobs } = this;
|
|
204
|
+
const job = jobs[key];
|
|
205
|
+
if (!job)
|
|
206
|
+
return;
|
|
207
|
+
delete jobs[key];
|
|
208
|
+
heap.remove(job);
|
|
209
|
+
if (config.debug)
|
|
210
|
+
this.log('job has been unregistered', key);
|
|
211
|
+
this.scheduleNextRun();
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Parse a job interval (whole seconds, as a string) into a positive integer.
|
|
215
|
+
*
|
|
216
|
+
* Returns `null` when the value cannot be parsed at all, so callers can choose
|
|
217
|
+
* between failing fast (`register`) and falling back (`setNextTrigger`).
|
|
218
|
+
*/
|
|
219
|
+
parseInterval(interval) {
|
|
220
|
+
const seconds = parseInt(interval, 10);
|
|
221
|
+
if (!Number.isFinite(seconds))
|
|
222
|
+
return null;
|
|
223
|
+
return Math.max(MIN_INTERVAL_SECONDS, seconds);
|
|
224
|
+
}
|
|
225
|
+
setNextTrigger(job) {
|
|
226
|
+
// `register` rejects an unparseable interval up front; this floor is the
|
|
227
|
+
// backstop for a job object mutated after registration (`cron.jobs` is
|
|
228
|
+
// public, mutable state) and is what actually guarantees the drain loop
|
|
229
|
+
// terminates. Never let `nextTrigger` land on `NaN` or on `now`.
|
|
230
|
+
job.nextTrigger = getTimestamp() + (this.parseInterval(job.interval) ?? MIN_INTERVAL_SECONDS);
|
|
231
|
+
}
|
|
232
|
+
log(text, key = null) {
|
|
233
|
+
if (!config.cron?.log)
|
|
234
|
+
return;
|
|
235
|
+
const tag = key ? `Cron::${key}` : `Cron`;
|
|
236
|
+
log.cron(`${tag} - ${text}:`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface HeapItem {
|
|
2
|
+
nextTrigger: number;
|
|
3
|
+
}
|
|
4
|
+
export default class MinHeap<T extends HeapItem> {
|
|
5
|
+
items: T[];
|
|
6
|
+
push(job: T): void;
|
|
7
|
+
pop(): T | undefined;
|
|
8
|
+
peek(): T | undefined;
|
|
9
|
+
bubbleUp(): void;
|
|
10
|
+
bubbleDown(): void;
|
|
11
|
+
remove(job: T): void;
|
|
12
|
+
isEmpty(): boolean;
|
|
13
|
+
}
|
package/dist/min-heap.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export default class MinHeap {
|
|
2
|
+
items = [];
|
|
3
|
+
push(job) {
|
|
4
|
+
this.items.push(job);
|
|
5
|
+
this.bubbleUp();
|
|
6
|
+
}
|
|
7
|
+
pop() {
|
|
8
|
+
if (this.items.length <= 1)
|
|
9
|
+
return this.items.pop();
|
|
10
|
+
const top = this.items[0];
|
|
11
|
+
const last = this.items.pop();
|
|
12
|
+
if (last === undefined)
|
|
13
|
+
return top;
|
|
14
|
+
this.items[0] = last;
|
|
15
|
+
this.bubbleDown();
|
|
16
|
+
return top;
|
|
17
|
+
}
|
|
18
|
+
peek() {
|
|
19
|
+
return this.items[0];
|
|
20
|
+
}
|
|
21
|
+
bubbleUp() {
|
|
22
|
+
let idx = this.items.length - 1;
|
|
23
|
+
while (idx > 0) {
|
|
24
|
+
const parentIdx = Math.floor((idx - 1) / 2);
|
|
25
|
+
if (this.items[idx].nextTrigger >= this.items[parentIdx].nextTrigger)
|
|
26
|
+
break;
|
|
27
|
+
[this.items[idx], this.items[parentIdx]] = [this.items[parentIdx], this.items[idx]];
|
|
28
|
+
idx = parentIdx;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
bubbleDown() {
|
|
32
|
+
let idx = 0;
|
|
33
|
+
const length = this.items.length;
|
|
34
|
+
while (true) {
|
|
35
|
+
const leftIdx = 2 * idx + 1;
|
|
36
|
+
const rightIdx = 2 * idx + 2;
|
|
37
|
+
let swapIdx = null;
|
|
38
|
+
if (leftIdx < length && this.items[leftIdx].nextTrigger < this.items[idx].nextTrigger) {
|
|
39
|
+
swapIdx = leftIdx;
|
|
40
|
+
}
|
|
41
|
+
if (rightIdx < length &&
|
|
42
|
+
this.items[rightIdx].nextTrigger < (swapIdx === null ? this.items[idx].nextTrigger : this.items[leftIdx].nextTrigger)) {
|
|
43
|
+
swapIdx = rightIdx;
|
|
44
|
+
}
|
|
45
|
+
if (swapIdx === null)
|
|
46
|
+
break;
|
|
47
|
+
[this.items[idx], this.items[swapIdx]] = [this.items[swapIdx], this.items[idx]];
|
|
48
|
+
idx = swapIdx;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
remove(job) {
|
|
52
|
+
const idx = this.items.indexOf(job);
|
|
53
|
+
if (idx === -1)
|
|
54
|
+
return;
|
|
55
|
+
const end = this.items.pop();
|
|
56
|
+
if (end === undefined)
|
|
57
|
+
return;
|
|
58
|
+
if (idx < this.items.length) {
|
|
59
|
+
this.items[idx] = end;
|
|
60
|
+
this.bubbleUp();
|
|
61
|
+
this.bubbleDown();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
isEmpty() {
|
|
65
|
+
return this.items.length === 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
interface RawSchedule {
|
|
7
|
+
kind?: string;
|
|
8
|
+
at?: string | number;
|
|
9
|
+
atMs?: number;
|
|
10
|
+
everyMs?: string | number;
|
|
11
|
+
expr?: string;
|
|
12
|
+
tz?: string;
|
|
13
|
+
}
|
|
14
|
+
interface RawPayload {
|
|
15
|
+
kind?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
interface RawJobInput {
|
|
21
|
+
name?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
schedule?: RawSchedule;
|
|
24
|
+
payload?: RawPayload;
|
|
25
|
+
delivery?: Record<string, unknown>;
|
|
26
|
+
sessionTarget?: string;
|
|
27
|
+
wakeMode?: string;
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
deleteAfterRun?: boolean;
|
|
30
|
+
job?: RawJobInput;
|
|
31
|
+
message?: string;
|
|
32
|
+
text?: string;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a schedule object. Infers kind from fields if missing.
|
|
37
|
+
*/
|
|
38
|
+
export declare function normalizeSchedule(raw: unknown): RawSchedule;
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a payload object. Infers kind from fields if missing.
|
|
41
|
+
*/
|
|
42
|
+
export declare function normalizePayload(raw: unknown): RawPayload;
|
|
43
|
+
export declare function recoverFlatParams(params: RawJobInput): RawJobInput;
|
|
44
|
+
/**
|
|
45
|
+
* Normalize a complete job input for creation.
|
|
46
|
+
* Applies all normalization: schedule, payload, defaults.
|
|
47
|
+
*/
|
|
48
|
+
export declare function normalizeJobInput(raw: RawJobInput): RawJobInput;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
* Normalize a schedule object. Infers kind from fields if missing.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeSchedule(raw) {
|
|
10
|
+
if (!raw || typeof raw !== 'object')
|
|
11
|
+
return raw;
|
|
12
|
+
const schedule = { ...raw };
|
|
13
|
+
// Infer kind from fields if missing
|
|
14
|
+
if (!schedule.kind) {
|
|
15
|
+
if (schedule.at || schedule.atMs)
|
|
16
|
+
schedule.kind = 'at';
|
|
17
|
+
else if (schedule.everyMs)
|
|
18
|
+
schedule.kind = 'every';
|
|
19
|
+
else if (schedule.expr)
|
|
20
|
+
schedule.kind = 'cron';
|
|
21
|
+
}
|
|
22
|
+
// Case normalization
|
|
23
|
+
if (typeof schedule.kind === 'string') {
|
|
24
|
+
schedule.kind = schedule.kind.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
// Legacy: atMs (number) -> at (ISO string)
|
|
27
|
+
if (schedule.atMs && !schedule.at) {
|
|
28
|
+
schedule.at = new Date(schedule.atMs).toISOString();
|
|
29
|
+
delete schedule.atMs;
|
|
30
|
+
}
|
|
31
|
+
// Coerce string everyMs to number
|
|
32
|
+
if (typeof schedule.everyMs === 'string') {
|
|
33
|
+
schedule.everyMs = Number(schedule.everyMs);
|
|
34
|
+
}
|
|
35
|
+
return schedule;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Normalize a payload object. Infers kind from fields if missing.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizePayload(raw) {
|
|
41
|
+
if (!raw || typeof raw !== 'object')
|
|
42
|
+
return raw;
|
|
43
|
+
const payload = { ...raw };
|
|
44
|
+
// Infer kind from fields
|
|
45
|
+
if (!payload.kind) {
|
|
46
|
+
if (payload.message)
|
|
47
|
+
payload.kind = 'agentTurn';
|
|
48
|
+
else if (payload.text)
|
|
49
|
+
payload.kind = 'systemEvent';
|
|
50
|
+
}
|
|
51
|
+
// Case normalization
|
|
52
|
+
if (typeof payload.kind === 'string') {
|
|
53
|
+
const lower = payload.kind.toLowerCase();
|
|
54
|
+
if (lower === 'agentturn')
|
|
55
|
+
payload.kind = 'agentTurn';
|
|
56
|
+
else if (lower === 'systemevent')
|
|
57
|
+
payload.kind = 'systemEvent';
|
|
58
|
+
}
|
|
59
|
+
return payload;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Recover a job object from flat parameters.
|
|
63
|
+
* AI models sometimes flatten nested fields to the top level.
|
|
64
|
+
*/
|
|
65
|
+
const JOB_KEYS = new Set([
|
|
66
|
+
'name', 'description', 'schedule', 'sessionTarget', 'payload',
|
|
67
|
+
'delivery', 'enabled', 'deleteAfterRun', 'wakeMode',
|
|
68
|
+
]);
|
|
69
|
+
export function recoverFlatParams(params) {
|
|
70
|
+
if (params.job && typeof params.job === 'object' && Object.keys(params.job).length > 0) {
|
|
71
|
+
return params.job;
|
|
72
|
+
}
|
|
73
|
+
const synthetic = {};
|
|
74
|
+
for (const key of Object.keys(params)) {
|
|
75
|
+
if (JOB_KEYS.has(key)) {
|
|
76
|
+
synthetic[key] = params[key];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// message/text are not JOB_KEYS but need to be recovered for payload wrapping
|
|
80
|
+
const message = params.message;
|
|
81
|
+
const text = params.text;
|
|
82
|
+
if (synthetic.schedule || synthetic.payload || message || text) {
|
|
83
|
+
// If message/text are at top level, wrap into payload
|
|
84
|
+
if (!synthetic.payload) {
|
|
85
|
+
if (message) {
|
|
86
|
+
synthetic.payload = { kind: 'agentTurn', message };
|
|
87
|
+
}
|
|
88
|
+
else if (text) {
|
|
89
|
+
synthetic.payload = { kind: 'systemEvent', text };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return synthetic;
|
|
93
|
+
}
|
|
94
|
+
return params;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Normalize a complete job input for creation.
|
|
98
|
+
* Applies all normalization: schedule, payload, defaults.
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeJobInput(raw) {
|
|
101
|
+
const job = { ...raw };
|
|
102
|
+
if (job.schedule) {
|
|
103
|
+
job.schedule = normalizeSchedule(job.schedule);
|
|
104
|
+
}
|
|
105
|
+
if (job.payload) {
|
|
106
|
+
job.payload = normalizePayload(job.payload);
|
|
107
|
+
}
|
|
108
|
+
// Default: enabled
|
|
109
|
+
if (job.enabled === undefined)
|
|
110
|
+
job.enabled = true;
|
|
111
|
+
// Default: wakeMode
|
|
112
|
+
if (!job.wakeMode)
|
|
113
|
+
job.wakeMode = 'now';
|
|
114
|
+
// Default: sessionTarget inferred from payload
|
|
115
|
+
if (!job.sessionTarget && job.payload) {
|
|
116
|
+
job.sessionTarget = job.payload.kind === 'systemEvent' ? 'main' : 'isolated';
|
|
117
|
+
}
|
|
118
|
+
// Default: deleteAfterRun for one-shot
|
|
119
|
+
if (job.deleteAfterRun === undefined && job.schedule?.kind === 'at') {
|
|
120
|
+
job.deleteAfterRun = true;
|
|
121
|
+
}
|
|
122
|
+
// Default: delivery for isolated agentTurn
|
|
123
|
+
if (!job.delivery && job.sessionTarget === 'isolated' && job.payload?.kind === 'agentTurn') {
|
|
124
|
+
job.delivery = { mode: 'announce' };
|
|
125
|
+
}
|
|
126
|
+
// Auto-generate name if missing
|
|
127
|
+
if (!job.name) {
|
|
128
|
+
job.name = inferName(job);
|
|
129
|
+
}
|
|
130
|
+
return job;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Infer a job name from schedule and payload.
|
|
134
|
+
*/
|
|
135
|
+
function inferName(job) {
|
|
136
|
+
const parts = [];
|
|
137
|
+
if (job.schedule?.kind === 'at')
|
|
138
|
+
parts.push('One-shot');
|
|
139
|
+
else if (job.schedule?.kind === 'every')
|
|
140
|
+
parts.push('Recurring');
|
|
141
|
+
else if (job.schedule?.kind === 'cron')
|
|
142
|
+
parts.push('Scheduled');
|
|
143
|
+
if (job.payload?.kind === 'agentTurn')
|
|
144
|
+
parts.push('agent task');
|
|
145
|
+
else if (job.payload?.kind === 'systemEvent')
|
|
146
|
+
parts.push('system event');
|
|
147
|
+
return parts.join(' ') || 'Unnamed job';
|
|
148
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory run log for job execution history.
|
|
3
|
+
* Stores recent execution results per job with auto-pruning.
|
|
4
|
+
*/
|
|
5
|
+
export interface RunLogEntry {
|
|
6
|
+
ts: number;
|
|
7
|
+
jobId: string;
|
|
8
|
+
status: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
runAtMs?: number;
|
|
12
|
+
durationMs?: number;
|
|
13
|
+
nextRunAtMs?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface RunLogInput {
|
|
16
|
+
jobId: string;
|
|
17
|
+
status: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
summary?: string;
|
|
20
|
+
runAtMs?: number;
|
|
21
|
+
durationMs?: number;
|
|
22
|
+
nextRunAtMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export default class RunLog {
|
|
25
|
+
maxEntries: number;
|
|
26
|
+
entries: Map<string, RunLogEntry[]>;
|
|
27
|
+
constructor(maxEntriesPerJob?: number);
|
|
28
|
+
/**
|
|
29
|
+
* Record a job execution result.
|
|
30
|
+
*/
|
|
31
|
+
record(entry: RunLogInput): void;
|
|
32
|
+
/**
|
|
33
|
+
* Get run history for a job.
|
|
34
|
+
*/
|
|
35
|
+
get(jobId: string, limit?: number): RunLogEntry[];
|
|
36
|
+
/**
|
|
37
|
+
* Remove all entries for a job.
|
|
38
|
+
*/
|
|
39
|
+
removeJob(jobId: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Clear all entries.
|
|
42
|
+
*/
|
|
43
|
+
clear(): void;
|
|
44
|
+
}
|
package/dist/run-log.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory run log for job execution history.
|
|
3
|
+
* Stores recent execution results per job with auto-pruning.
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_MAX_ENTRIES_PER_JOB = 100;
|
|
6
|
+
export default class RunLog {
|
|
7
|
+
maxEntries;
|
|
8
|
+
entries;
|
|
9
|
+
constructor(maxEntriesPerJob = DEFAULT_MAX_ENTRIES_PER_JOB) {
|
|
10
|
+
this.maxEntries = maxEntriesPerJob;
|
|
11
|
+
this.entries = new Map();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Record a job execution result.
|
|
15
|
+
*/
|
|
16
|
+
record(entry) {
|
|
17
|
+
const log = {
|
|
18
|
+
ts: Date.now(),
|
|
19
|
+
jobId: entry.jobId,
|
|
20
|
+
status: entry.status,
|
|
21
|
+
error: entry.error,
|
|
22
|
+
summary: entry.summary,
|
|
23
|
+
runAtMs: entry.runAtMs,
|
|
24
|
+
durationMs: entry.durationMs,
|
|
25
|
+
nextRunAtMs: entry.nextRunAtMs,
|
|
26
|
+
};
|
|
27
|
+
if (!this.entries.has(entry.jobId)) {
|
|
28
|
+
this.entries.set(entry.jobId, []);
|
|
29
|
+
}
|
|
30
|
+
const logs = this.entries.get(entry.jobId);
|
|
31
|
+
if (!logs)
|
|
32
|
+
return;
|
|
33
|
+
logs.push(log);
|
|
34
|
+
// Auto-prune
|
|
35
|
+
if (logs.length > this.maxEntries) {
|
|
36
|
+
logs.splice(0, logs.length - this.maxEntries);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get run history for a job.
|
|
41
|
+
*/
|
|
42
|
+
get(jobId, limit = 20) {
|
|
43
|
+
const logs = this.entries.get(jobId);
|
|
44
|
+
if (!logs)
|
|
45
|
+
return [];
|
|
46
|
+
return logs.slice(-limit).reverse();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove all entries for a job.
|
|
50
|
+
*/
|
|
51
|
+
removeJob(jobId) {
|
|
52
|
+
this.entries.delete(jobId);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Clear all entries.
|
|
56
|
+
*/
|
|
57
|
+
clear() {
|
|
58
|
+
this.entries.clear();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface AtSchedule {
|
|
2
|
+
kind: 'at';
|
|
3
|
+
at: string | number;
|
|
4
|
+
}
|
|
5
|
+
export interface EverySchedule {
|
|
6
|
+
kind: 'every';
|
|
7
|
+
everyMs: number;
|
|
8
|
+
anchorMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface CronSchedule {
|
|
11
|
+
kind: 'cron';
|
|
12
|
+
expr: string;
|
|
13
|
+
tz?: string;
|
|
14
|
+
}
|
|
15
|
+
export type Schedule = AtSchedule | EverySchedule | CronSchedule;
|
|
16
|
+
/**
|
|
17
|
+
* Compute the next run time for a schedule.
|
|
18
|
+
*/
|
|
19
|
+
export declare function computeNextRunAtMs(schedule: Schedule, nowMs: number): number | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Validate a schedule definition.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateSchedule(schedule: Schedule): void;
|