@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/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 +21 -0
- package/dist/main.js +107 -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 +125 -0
- package/dist/service.js +446 -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/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;
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
* Compute the next run time for a schedule.
|
|
12
|
+
*/
|
|
13
|
+
export function computeNextRunAtMs(schedule, nowMs) {
|
|
14
|
+
if (schedule.kind === 'at') {
|
|
15
|
+
const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
|
|
16
|
+
if (!Number.isFinite(atMs))
|
|
17
|
+
return undefined;
|
|
18
|
+
return atMs > nowMs ? atMs : undefined;
|
|
19
|
+
}
|
|
20
|
+
if (schedule.kind === 'every') {
|
|
21
|
+
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
|
22
|
+
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
|
23
|
+
if (nowMs < anchor)
|
|
24
|
+
return anchor;
|
|
25
|
+
const elapsed = nowMs - anchor;
|
|
26
|
+
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
|
|
27
|
+
return anchor + steps * everyMs;
|
|
28
|
+
}
|
|
29
|
+
if (schedule.kind === 'cron') {
|
|
30
|
+
const tz = schedule.tz?.trim() || undefined;
|
|
31
|
+
// Round nowMs down to the current second to avoid sub-second drift
|
|
32
|
+
const nowSecondMs = Math.floor(nowMs / 1000) * 1000;
|
|
33
|
+
return nextOccurrence(schedule.expr.trim(), nowSecondMs, tz);
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validate a schedule definition.
|
|
39
|
+
*/
|
|
40
|
+
export function validateSchedule(schedule) {
|
|
41
|
+
if (!schedule || typeof schedule !== 'object') {
|
|
42
|
+
throw new Error('Schedule must be an object');
|
|
43
|
+
}
|
|
44
|
+
if (schedule.kind === 'at') {
|
|
45
|
+
const atMs = typeof schedule.at === 'number' ? schedule.at : Date.parse(schedule.at);
|
|
46
|
+
if (!Number.isFinite(atMs)) {
|
|
47
|
+
throw new Error(`Invalid "at" timestamp: "${schedule.at}"`);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (schedule.kind === 'every') {
|
|
52
|
+
if (typeof schedule.everyMs !== 'number' || schedule.everyMs < 1) {
|
|
53
|
+
throw new Error(`"every" schedule requires everyMs >= 1, got: ${schedule.everyMs}`);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (schedule.kind === 'cron') {
|
|
58
|
+
if (typeof schedule.expr !== 'string' || !schedule.expr.trim()) {
|
|
59
|
+
throw new Error('"cron" schedule requires a non-empty expr string');
|
|
60
|
+
}
|
|
61
|
+
validateCronExpression(schedule.expr.trim());
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Unknown schedule kind: "${schedule.kind}"`);
|
|
65
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import MinHeap, { type HeapItem } from './min-heap.js';
|
|
2
|
+
import { type Job, type JobPatch } from './job.js';
|
|
3
|
+
import RunLog from './run-log.js';
|
|
4
|
+
interface HeapEntry extends HeapItem {
|
|
5
|
+
key: string;
|
|
6
|
+
}
|
|
7
|
+
interface JobDueResult {
|
|
8
|
+
status?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
}
|
|
12
|
+
interface ExecuteResult {
|
|
13
|
+
status: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
summary?: string;
|
|
16
|
+
durationMs?: number;
|
|
17
|
+
deleted?: boolean;
|
|
18
|
+
/** Only set when `status` is `'skipped'`. */
|
|
19
|
+
reason?: 'not due' | 'already running' | 'removed';
|
|
20
|
+
}
|
|
21
|
+
interface ServiceStatus {
|
|
22
|
+
started: boolean;
|
|
23
|
+
jobCount: number;
|
|
24
|
+
nextWakeAtMs: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
interface ListOptions {
|
|
27
|
+
includeDisabled?: boolean;
|
|
28
|
+
}
|
|
29
|
+
type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
|
|
30
|
+
export default class CronService {
|
|
31
|
+
#private;
|
|
32
|
+
jobs: Map<string, Job>;
|
|
33
|
+
heap: MinHeap<HeapEntry>;
|
|
34
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
35
|
+
running: boolean;
|
|
36
|
+
runLog: RunLog;
|
|
37
|
+
started: boolean;
|
|
38
|
+
onJobDue: OnJobDueCallback | null;
|
|
39
|
+
constructor();
|
|
40
|
+
/**
|
|
41
|
+
* Start the service. Loads jobs from store (if any), arms timer.
|
|
42
|
+
*/
|
|
43
|
+
start(initialJobs?: Job[]): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Stop the service. Clears timer.
|
|
46
|
+
*/
|
|
47
|
+
stop(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Get service status.
|
|
50
|
+
*/
|
|
51
|
+
status(): ServiceStatus;
|
|
52
|
+
/**
|
|
53
|
+
* List jobs, optionally including disabled ones.
|
|
54
|
+
*/
|
|
55
|
+
list(opts?: ListOptions): Job[];
|
|
56
|
+
/**
|
|
57
|
+
* Get a single job by ID.
|
|
58
|
+
*/
|
|
59
|
+
get(id: string): Job | null;
|
|
60
|
+
/**
|
|
61
|
+
* Add a new job. Input is normalized for AI compatibility.
|
|
62
|
+
*/
|
|
63
|
+
add(rawInput: Record<string, unknown>): Promise<Job>;
|
|
64
|
+
/**
|
|
65
|
+
* Update an existing job.
|
|
66
|
+
*/
|
|
67
|
+
update(id: string, patch: JobPatch): Promise<Job>;
|
|
68
|
+
/**
|
|
69
|
+
* Remove a job.
|
|
70
|
+
*/
|
|
71
|
+
remove(id: string): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Manually trigger a job.
|
|
74
|
+
*/
|
|
75
|
+
run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
|
|
76
|
+
/**
|
|
77
|
+
* Get run history for a job.
|
|
78
|
+
*/
|
|
79
|
+
runs(id: string, limit?: number): ReturnType<RunLog['get']>;
|
|
80
|
+
armTimer(): void;
|
|
81
|
+
onTimer(): Promise<void>;
|
|
82
|
+
findDueJobs(nowMs: number): Job[];
|
|
83
|
+
/**
|
|
84
|
+
* Execute a job in three phases:
|
|
85
|
+
*
|
|
86
|
+
* 1. claim (locked) - take ownership of the job, detach it from the heap
|
|
87
|
+
* 2. invoke (UNLOCKED) - await the consumer callback
|
|
88
|
+
* 3. settle (locked) - apply the result, log it, re-insert into the heap
|
|
89
|
+
*
|
|
90
|
+
* The critical section deliberately excludes phase 2. `onJobDue` is
|
|
91
|
+
* arbitrary, unbounded consumer code; awaiting it under the module-global
|
|
92
|
+
* lock is what wedged every subsequent `locked()` call (add/update/remove)
|
|
93
|
+
* when a callback never settled.
|
|
94
|
+
*
|
|
95
|
+
* `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
|
|
96
|
+
* jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
|
|
97
|
+
* That entry point is a `#private` method rather than a parameter on this
|
|
98
|
+
* one: as a published `alreadyClaimed` boolean it was a supported way for a
|
|
99
|
+
* consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
|
|
100
|
+
* for and allows concurrent `onJobDue` invocations for the same job.
|
|
101
|
+
*/
|
|
102
|
+
executeJob(job: Job): Promise<ExecuteResult>;
|
|
103
|
+
/**
|
|
104
|
+
* Phase 1 - claim. Must be called while holding the lock.
|
|
105
|
+
*
|
|
106
|
+
* Returns `null` on a successful claim, or the reason the claim was refused.
|
|
107
|
+
* "already running" is what makes a second `run()` report a skip instead of
|
|
108
|
+
* launching a concurrent invocation. "removed" covers the job being deleted
|
|
109
|
+
* between `run()`'s unlocked lookup and this lock turn - claiming then would
|
|
110
|
+
* `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
|
|
111
|
+
* belong to a replacement.
|
|
112
|
+
*
|
|
113
|
+
* Detaching from the heap here (rather than relying on phase 3 to push a
|
|
114
|
+
* fresh entry) is what keeps manual runs from permanently duplicating heap
|
|
115
|
+
* entries.
|
|
116
|
+
*/
|
|
117
|
+
claimJob(job: Job): 'already running' | 'removed' | null;
|
|
118
|
+
/**
|
|
119
|
+
* Phase 3 - settle. Must be called while holding the lock.
|
|
120
|
+
*/
|
|
121
|
+
settleJob(job: Job, status: string, error: string | undefined, summary: string | undefined, startMs: number, durationMs: number): ExecuteResult;
|
|
122
|
+
removeFromHeap(id: string): void;
|
|
123
|
+
log(message: string): void;
|
|
124
|
+
}
|
|
125
|
+
export {};
|