@oka-core/reason 0.2.15 → 0.2.16
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/dist/abort-controller.d.ts +19 -0
- package/dist/abort-controller.d.ts.map +1 -0
- package/dist/abort-controller.js +53 -0
- package/dist/activity-tracker.d.ts +48 -0
- package/dist/activity-tracker.d.ts.map +1 -0
- package/dist/activity-tracker.js +80 -0
- package/dist/analytics.d.ts +49 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +88 -0
- package/dist/array.d.ts +12 -0
- package/dist/array.d.ts.map +1 -0
- package/dist/array.js +20 -0
- package/dist/async-context.d.ts +20 -0
- package/dist/async-context.d.ts.map +1 -0
- package/dist/async-context.js +25 -0
- package/dist/binary-check.d.ts +16 -0
- package/dist/binary-check.d.ts.map +1 -0
- package/dist/binary-check.js +43 -0
- package/dist/buffered-writer.d.ts +30 -0
- package/dist/buffered-writer.d.ts.map +1 -0
- package/dist/buffered-writer.js +87 -0
- package/dist/circular-buffer.d.ts +28 -0
- package/dist/circular-buffer.d.ts.map +1 -0
- package/dist/circular-buffer.js +61 -0
- package/dist/cleanup-registry.d.ts +23 -0
- package/dist/cleanup-registry.d.ts.map +1 -0
- package/dist/cleanup-registry.js +34 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +32 -10
- package/dist/combined-abort-signal.d.ts +25 -0
- package/dist/combined-abort-signal.d.ts.map +1 -0
- package/dist/combined-abort-signal.js +47 -0
- package/dist/cron-lock.d.ts +29 -0
- package/dist/cron-lock.d.ts.map +1 -0
- package/dist/cron-lock.js +127 -0
- package/dist/cron-scheduler.d.ts +41 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +189 -0
- package/dist/cron-tasks.d.ts +86 -0
- package/dist/cron-tasks.d.ts.map +1 -0
- package/dist/cron-tasks.js +205 -0
- package/dist/cron.d.ts +35 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +215 -0
- package/dist/env.d.ts +26 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +50 -0
- package/dist/errors.d.ts +99 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +214 -0
- package/dist/format.d.ts +21 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +48 -0
- package/dist/fps-tracker.d.ts +22 -0
- package/dist/fps-tracker.d.ts.map +1 -0
- package/dist/fps-tracker.js +44 -0
- package/dist/graceful-shutdown.d.ts +35 -0
- package/dist/graceful-shutdown.d.ts.map +1 -0
- package/dist/graceful-shutdown.js +89 -0
- package/dist/hash.d.ts +21 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +31 -0
- package/dist/heap-diagnostics.d.ts +68 -0
- package/dist/heap-diagnostics.d.ts.map +1 -0
- package/dist/heap-diagnostics.js +110 -0
- package/dist/idle-timeout.d.ts +21 -0
- package/dist/idle-timeout.d.ts.map +1 -0
- package/dist/idle-timeout.js +42 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/intl.d.ts +18 -0
- package/dist/intl.d.ts.map +1 -0
- package/dist/intl.js +75 -0
- package/dist/jsonl.d.ts +16 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/jsonl.js +60 -0
- package/dist/lazy-schema.d.ts +6 -0
- package/dist/lazy-schema.d.ts.map +1 -0
- package/dist/lazy-schema.js +8 -0
- package/dist/memo.d.ts +64 -0
- package/dist/memo.d.ts.map +1 -0
- package/dist/memo.js +162 -0
- package/dist/pkce.d.ts +13 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +28 -0
- package/dist/priority-queue.d.ts +36 -0
- package/dist/priority-queue.d.ts.map +1 -0
- package/dist/priority-queue.js +97 -0
- package/dist/process-utils.d.ts +20 -0
- package/dist/process-utils.d.ts.map +1 -0
- package/dist/process-utils.js +54 -0
- package/dist/query-guard.d.ts +34 -0
- package/dist/query-guard.d.ts.map +1 -0
- package/dist/query-guard.js +74 -0
- package/dist/retry.d.ts +60 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +89 -0
- package/dist/schemas.d.ts +6 -6
- package/dist/secrets.d.ts +44 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +115 -0
- package/dist/semantic-types.d.ts +39 -0
- package/dist/semantic-types.d.ts.map +1 -0
- package/dist/semantic-types.js +49 -0
- package/dist/sequential.d.ts +21 -0
- package/dist/sequential.d.ts.map +1 -0
- package/dist/sequential.js +49 -0
- package/dist/signal.d.ts +29 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +39 -0
- package/dist/sleep.d.ts +21 -0
- package/dist/sleep.d.ts.map +1 -0
- package/dist/sleep.js +58 -0
- package/dist/slow-ops.d.ts +41 -0
- package/dist/slow-ops.d.ts.map +1 -0
- package/dist/slow-ops.js +133 -0
- package/dist/store.d.ts +20 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +34 -0
- package/dist/stream.d.ts +29 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +92 -0
- package/dist/string-utils.d.ts +46 -0
- package/dist/string-utils.d.ts.map +1 -0
- package/dist/string-utils.js +69 -0
- package/dist/strip-bom.d.ts +8 -0
- package/dist/strip-bom.d.ts.map +1 -0
- package/dist/strip-bom.js +10 -0
- package/dist/subprocess-env.d.ts +25 -0
- package/dist/subprocess-env.d.ts.map +1 -0
- package/dist/subprocess-env.js +55 -0
- package/dist/temp-file.d.ts +18 -0
- package/dist/temp-file.d.ts.map +1 -0
- package/dist/temp-file.js +26 -0
- package/dist/tool-contract.d.ts +85 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +101 -0
- package/dist/tools/read.d.ts +2 -10
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +662 -537
- package/dist/tools/write.d.ts +3 -2
- package/dist/tools/write.d.ts.map +1 -1
- package/dist/tools/write.js +329 -177
- package/dist/uuid.d.ts +20 -0
- package/dist/uuid.d.ts.map +1 -0
- package/dist/uuid.js +28 -0
- package/dist/validation.d.ts +64 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +236 -0
- package/dist/with-resolvers.d.ts +12 -0
- package/dist/with-resolvers.d.ts.map +1 -0
- package/dist/with-resolvers.js +14 -0
- package/dist/xml-escape.d.ts +12 -0
- package/dist/xml-escape.d.ts.map +1 -0
- package/dist/xml-escape.js +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polling-based cron scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Simplified extraction from CC's cronScheduler — strips chokidar file watching
|
|
5
|
+
* (uses polling), bootstrap state, analytics, and session tasks. Designed for
|
|
6
|
+
* standalone use with file-backed cron tasks.
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_CRON_JITTER_CONFIG, buildMissedTaskNotification, findMissedTasks, jitteredNextCronRunMs, oneShotJitteredNextCronRunMs, readCronTasks, removeCronTasks, markCronTasksFired, } from "./cron-tasks.js";
|
|
9
|
+
import { tryAcquireSchedulerLock, releaseSchedulerLock } from "./cron-lock.js";
|
|
10
|
+
const CHECK_INTERVAL_MS = 1000;
|
|
11
|
+
const LOCK_PROBE_INTERVAL_MS = 5000;
|
|
12
|
+
/**
|
|
13
|
+
* True when a recurring task was created more than maxAgeMs ago.
|
|
14
|
+
* Permanent tasks never age. maxAgeMs === 0 means unlimited.
|
|
15
|
+
*/
|
|
16
|
+
export function isRecurringTaskAged(t, nowMs, maxAgeMs) {
|
|
17
|
+
if (maxAgeMs === 0)
|
|
18
|
+
return false;
|
|
19
|
+
return Boolean(t.recurring && !t.permanent && nowMs - t.createdAt >= maxAgeMs);
|
|
20
|
+
}
|
|
21
|
+
export function createCronScheduler(options) {
|
|
22
|
+
const { dir, ownerId, onFire, onFireTask, onMissed, filter, isKilled, checkIntervalMs = CHECK_INTERVAL_MS, } = options;
|
|
23
|
+
const jitterCfg = options.jitterConfig ?? DEFAULT_CRON_JITTER_CONFIG;
|
|
24
|
+
const lockOpts = { dir, ownerId };
|
|
25
|
+
let tasks = [];
|
|
26
|
+
const nextFireAt = new Map();
|
|
27
|
+
const missedAsked = new Set();
|
|
28
|
+
const inFlight = new Set();
|
|
29
|
+
let checkTimer = null;
|
|
30
|
+
let lockProbeTimer = null;
|
|
31
|
+
let stopped = false;
|
|
32
|
+
let isOwner = false;
|
|
33
|
+
async function load(initial) {
|
|
34
|
+
const next = await readCronTasks(dir);
|
|
35
|
+
if (stopped)
|
|
36
|
+
return;
|
|
37
|
+
tasks = next;
|
|
38
|
+
if (!initial)
|
|
39
|
+
return;
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const missed = findMissedTasks(next, now).filter((t) => !t.recurring && !missedAsked.has(t.id) && (!filter || filter(t)));
|
|
42
|
+
if (missed.length > 0) {
|
|
43
|
+
for (const t of missed) {
|
|
44
|
+
missedAsked.add(t.id);
|
|
45
|
+
nextFireAt.set(t.id, Infinity);
|
|
46
|
+
}
|
|
47
|
+
if (onMissed) {
|
|
48
|
+
onMissed(missed);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
onFire(buildMissedTaskNotification(missed));
|
|
52
|
+
}
|
|
53
|
+
void removeCronTasks(missed.map((t) => t.id), dir).catch(() => { });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function check() {
|
|
57
|
+
if (isKilled?.())
|
|
58
|
+
return;
|
|
59
|
+
if (!isOwner)
|
|
60
|
+
return;
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const firedFileRecurring = [];
|
|
64
|
+
for (const t of tasks) {
|
|
65
|
+
if (filter && !filter(t))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(t.id);
|
|
68
|
+
if (inFlight.has(t.id))
|
|
69
|
+
continue;
|
|
70
|
+
let next = nextFireAt.get(t.id);
|
|
71
|
+
if (next === undefined) {
|
|
72
|
+
next = t.recurring
|
|
73
|
+
? (jitteredNextCronRunMs(t.cron, t.lastFiredAt ?? t.createdAt, t.id, jitterCfg) ?? Infinity)
|
|
74
|
+
: (oneShotJitteredNextCronRunMs(t.cron, t.createdAt, t.id, jitterCfg) ?? Infinity);
|
|
75
|
+
nextFireAt.set(t.id, next);
|
|
76
|
+
}
|
|
77
|
+
if (now < next)
|
|
78
|
+
continue;
|
|
79
|
+
// Fire
|
|
80
|
+
if (onFireTask) {
|
|
81
|
+
onFireTask(t);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
onFire(t.prompt);
|
|
85
|
+
}
|
|
86
|
+
const aged = isRecurringTaskAged(t, now, jitterCfg.recurringMaxAgeMs);
|
|
87
|
+
if (t.recurring && !aged) {
|
|
88
|
+
const newNext = jitteredNextCronRunMs(t.cron, now, t.id, jitterCfg) ?? Infinity;
|
|
89
|
+
nextFireAt.set(t.id, newNext);
|
|
90
|
+
firedFileRecurring.push(t.id);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// One-shot or aged-out recurring: delete
|
|
94
|
+
inFlight.add(t.id);
|
|
95
|
+
void removeCronTasks([t.id], dir)
|
|
96
|
+
.catch(() => { })
|
|
97
|
+
.finally(() => inFlight.delete(t.id));
|
|
98
|
+
nextFireAt.delete(t.id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Batch lastFiredAt write
|
|
102
|
+
if (firedFileRecurring.length > 0) {
|
|
103
|
+
for (const id of firedFileRecurring)
|
|
104
|
+
inFlight.add(id);
|
|
105
|
+
void markCronTasksFired(firedFileRecurring, now, dir)
|
|
106
|
+
.catch(() => { })
|
|
107
|
+
.finally(() => {
|
|
108
|
+
for (const id of firedFileRecurring)
|
|
109
|
+
inFlight.delete(id);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (seen.size === 0) {
|
|
113
|
+
nextFireAt.clear();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const id of nextFireAt.keys()) {
|
|
117
|
+
if (!seen.has(id))
|
|
118
|
+
nextFireAt.delete(id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function enable() {
|
|
122
|
+
if (stopped)
|
|
123
|
+
return;
|
|
124
|
+
isOwner = await tryAcquireSchedulerLock(lockOpts).catch(() => false);
|
|
125
|
+
if (stopped) {
|
|
126
|
+
if (isOwner) {
|
|
127
|
+
isOwner = false;
|
|
128
|
+
void releaseSchedulerLock(lockOpts);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!isOwner) {
|
|
133
|
+
lockProbeTimer = setInterval(() => {
|
|
134
|
+
void tryAcquireSchedulerLock(lockOpts)
|
|
135
|
+
.then((owned) => {
|
|
136
|
+
if (stopped) {
|
|
137
|
+
if (owned)
|
|
138
|
+
void releaseSchedulerLock(lockOpts);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (owned) {
|
|
142
|
+
isOwner = true;
|
|
143
|
+
if (lockProbeTimer) {
|
|
144
|
+
clearInterval(lockProbeTimer);
|
|
145
|
+
lockProbeTimer = null;
|
|
146
|
+
}
|
|
147
|
+
// Load tasks now that we own the lock
|
|
148
|
+
void load(true);
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
.catch(() => { });
|
|
152
|
+
}, LOCK_PROBE_INTERVAL_MS);
|
|
153
|
+
lockProbeTimer.unref?.();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
void load(true);
|
|
157
|
+
checkTimer = setInterval(check, checkIntervalMs);
|
|
158
|
+
checkTimer.unref?.();
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
start() {
|
|
162
|
+
stopped = false;
|
|
163
|
+
void enable();
|
|
164
|
+
},
|
|
165
|
+
stop() {
|
|
166
|
+
stopped = true;
|
|
167
|
+
if (checkTimer) {
|
|
168
|
+
clearInterval(checkTimer);
|
|
169
|
+
checkTimer = null;
|
|
170
|
+
}
|
|
171
|
+
if (lockProbeTimer) {
|
|
172
|
+
clearInterval(lockProbeTimer);
|
|
173
|
+
lockProbeTimer = null;
|
|
174
|
+
}
|
|
175
|
+
if (isOwner) {
|
|
176
|
+
isOwner = false;
|
|
177
|
+
void releaseSchedulerLock(lockOpts);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
getNextFireTime() {
|
|
181
|
+
let min = Infinity;
|
|
182
|
+
for (const t of nextFireAt.values()) {
|
|
183
|
+
if (t < min)
|
|
184
|
+
min = t;
|
|
185
|
+
}
|
|
186
|
+
return min === Infinity ? null : min;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed cron task persistence.
|
|
3
|
+
*
|
|
4
|
+
* Tasks are stored in `<dir>/scheduled_tasks.json` with the shape:
|
|
5
|
+
* { "tasks": [{ id, cron, prompt, createdAt, lastFiredAt?, recurring?, permanent? }] }
|
|
6
|
+
*
|
|
7
|
+
* Provides CRUD operations, next-run computation with jitter, and missed-task detection.
|
|
8
|
+
*/
|
|
9
|
+
export type CronTask = {
|
|
10
|
+
id: string;
|
|
11
|
+
/** 5-field cron string (local time). */
|
|
12
|
+
cron: string;
|
|
13
|
+
/** Prompt or payload to deliver when the task fires. */
|
|
14
|
+
prompt: string;
|
|
15
|
+
/** Epoch ms when the task was created. */
|
|
16
|
+
createdAt: number;
|
|
17
|
+
/** Epoch ms of the most recent fire (recurring tasks only). */
|
|
18
|
+
lastFiredAt?: number;
|
|
19
|
+
/** When true, reschedules after firing instead of being deleted. */
|
|
20
|
+
recurring?: boolean;
|
|
21
|
+
/** When true, exempt from recurringMaxAgeMs auto-expiry. */
|
|
22
|
+
permanent?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Path to the cron tasks file within `dir`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getCronFilePath(dir: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Read and parse scheduled_tasks.json. Returns empty list if missing or malformed.
|
|
30
|
+
* Tasks with invalid cron strings are silently dropped.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readCronTasks(dir: string): Promise<CronTask[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Overwrite the tasks file. Creates parent directory if missing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function writeCronTasks(tasks: CronTask[], dir: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Append a task. Returns the generated id.
|
|
39
|
+
*/
|
|
40
|
+
export declare function addCronTask(dir: string, cron: string, prompt: string, recurring: boolean): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Remove tasks by id. No-op if none match.
|
|
43
|
+
*/
|
|
44
|
+
export declare function removeCronTasks(ids: string[], dir: string): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Stamp `lastFiredAt` on the given recurring tasks and write back.
|
|
47
|
+
*/
|
|
48
|
+
export declare function markCronTasksFired(ids: string[], firedAt: number, dir: string): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Next fire time in epoch ms for a cron string, strictly after `fromMs`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function nextCronRunMs(cron: string, fromMs: number): number | null;
|
|
53
|
+
export type CronJitterConfig = {
|
|
54
|
+
/** Recurring forward delay as fraction of interval between fires. */
|
|
55
|
+
recurringFrac: number;
|
|
56
|
+
/** Upper bound on recurring forward delay. */
|
|
57
|
+
recurringCapMs: number;
|
|
58
|
+
/** One-shot backward lead: max ms a task may fire early. */
|
|
59
|
+
oneShotMaxMs: number;
|
|
60
|
+
/** One-shot backward lead: min ms to fire early. */
|
|
61
|
+
oneShotFloorMs: number;
|
|
62
|
+
/** Jitter fires on minutes where minute % N === 0. */
|
|
63
|
+
oneShotMinuteMod: number;
|
|
64
|
+
/** Recurring tasks auto-expire after this many ms (0 = unlimited). */
|
|
65
|
+
recurringMaxAgeMs: number;
|
|
66
|
+
};
|
|
67
|
+
export declare const DEFAULT_CRON_JITTER_CONFIG: CronJitterConfig;
|
|
68
|
+
/**
|
|
69
|
+
* Next fire time for recurring tasks, with deterministic forward jitter
|
|
70
|
+
* proportional to the interval between fires.
|
|
71
|
+
*/
|
|
72
|
+
export declare function jitteredNextCronRunMs(cron: string, fromMs: number, taskId: string, cfg?: CronJitterConfig): number | null;
|
|
73
|
+
/**
|
|
74
|
+
* Next fire time for one-shot tasks, with deterministic backward jitter
|
|
75
|
+
* on round minutes (mod oneShotMinuteMod).
|
|
76
|
+
*/
|
|
77
|
+
export declare function oneShotJitteredNextCronRunMs(cron: string, fromMs: number, taskId: string, cfg?: CronJitterConfig): number | null;
|
|
78
|
+
/**
|
|
79
|
+
* Tasks whose next scheduled run (from createdAt) is in the past.
|
|
80
|
+
*/
|
|
81
|
+
export declare function findMissedTasks(tasks: CronTask[], nowMs: number): CronTask[];
|
|
82
|
+
/**
|
|
83
|
+
* Build a notification message for missed tasks.
|
|
84
|
+
*/
|
|
85
|
+
export declare function buildMissedTaskNotification(missed: CronTask[]): string;
|
|
86
|
+
//# sourceMappingURL=cron-tasks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cron-tasks.d.ts","sourceRoot":"","sources":["../src/cron-tasks.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAWH,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAMF;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CA4CpE;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,QAAQ,EAAE,EACjB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAQf;AAED;;GAEG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,OAAO,GACjB,OAAO,CAAC,MAAM,CAAC,CAajB;AAED;;GAEG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EAAE,EACb,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EAAE,EACb,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAaf;AAID;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKzE;AAID,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qEAAqE;IACrE,aAAa,EAAE,MAAM,CAAC;IACtB,8CAA8C;IAC9C,cAAc,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,cAAc,EAAE,MAAM,CAAC;IACvB,sDAAsD;IACtD,gBAAgB,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,iBAAiB,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF,eAAO,MAAM,0BAA0B,EAAE,gBAOxC,CAAC;AAUF;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,gBAA6C,GACjD,MAAM,GAAG,IAAI,CAUf;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,gBAA6C,GACjD,MAAM,GAAG,IAAI,CAQf;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,EAAE,CAK5E;AAED;;GAEG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,CAmBtE"}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed cron task persistence.
|
|
3
|
+
*
|
|
4
|
+
* Tasks are stored in `<dir>/scheduled_tasks.json` with the shape:
|
|
5
|
+
* { "tasks": [{ id, cron, prompt, createdAt, lastFiredAt?, recurring?, permanent? }] }
|
|
6
|
+
*
|
|
7
|
+
* Provides CRUD operations, next-run computation with jitter, and missed-task detection.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from "crypto";
|
|
10
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
|
+
import { computeNextCronRun, cronToHuman, parseCronExpression, } from "./cron.js";
|
|
13
|
+
const DEFAULT_FILE_NAME = "scheduled_tasks.json";
|
|
14
|
+
/**
|
|
15
|
+
* Path to the cron tasks file within `dir`.
|
|
16
|
+
*/
|
|
17
|
+
export function getCronFilePath(dir) {
|
|
18
|
+
return join(dir, DEFAULT_FILE_NAME);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read and parse scheduled_tasks.json. Returns empty list if missing or malformed.
|
|
22
|
+
* Tasks with invalid cron strings are silently dropped.
|
|
23
|
+
*/
|
|
24
|
+
export async function readCronTasks(dir) {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = await readFile(getCronFilePath(dir), { encoding: "utf-8" });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
if (!parsed || typeof parsed !== "object")
|
|
40
|
+
return [];
|
|
41
|
+
const file = parsed;
|
|
42
|
+
if (!Array.isArray(file.tasks))
|
|
43
|
+
return [];
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const t of file.tasks) {
|
|
46
|
+
if (!t ||
|
|
47
|
+
typeof t.id !== "string" ||
|
|
48
|
+
typeof t.cron !== "string" ||
|
|
49
|
+
typeof t.prompt !== "string" ||
|
|
50
|
+
typeof t.createdAt !== "number") {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!parseCronExpression(t.cron))
|
|
54
|
+
continue;
|
|
55
|
+
out.push({
|
|
56
|
+
id: t.id,
|
|
57
|
+
cron: t.cron,
|
|
58
|
+
prompt: t.prompt,
|
|
59
|
+
createdAt: t.createdAt,
|
|
60
|
+
...(typeof t.lastFiredAt === "number"
|
|
61
|
+
? { lastFiredAt: t.lastFiredAt }
|
|
62
|
+
: {}),
|
|
63
|
+
...(t.recurring ? { recurring: true } : {}),
|
|
64
|
+
...(t.permanent ? { permanent: true } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Overwrite the tasks file. Creates parent directory if missing.
|
|
71
|
+
*/
|
|
72
|
+
export async function writeCronTasks(tasks, dir) {
|
|
73
|
+
await mkdir(dirname(getCronFilePath(dir)), { recursive: true });
|
|
74
|
+
const body = { tasks };
|
|
75
|
+
await writeFile(getCronFilePath(dir), JSON.stringify(body, null, 2) + "\n", "utf-8");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Append a task. Returns the generated id.
|
|
79
|
+
*/
|
|
80
|
+
export async function addCronTask(dir, cron, prompt, recurring) {
|
|
81
|
+
const id = randomUUID().slice(0, 8);
|
|
82
|
+
const task = {
|
|
83
|
+
id,
|
|
84
|
+
cron,
|
|
85
|
+
prompt,
|
|
86
|
+
createdAt: Date.now(),
|
|
87
|
+
...(recurring ? { recurring: true } : {}),
|
|
88
|
+
};
|
|
89
|
+
const tasks = await readCronTasks(dir);
|
|
90
|
+
tasks.push(task);
|
|
91
|
+
await writeCronTasks(tasks, dir);
|
|
92
|
+
return id;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Remove tasks by id. No-op if none match.
|
|
96
|
+
*/
|
|
97
|
+
export async function removeCronTasks(ids, dir) {
|
|
98
|
+
if (ids.length === 0)
|
|
99
|
+
return;
|
|
100
|
+
const idSet = new Set(ids);
|
|
101
|
+
const tasks = await readCronTasks(dir);
|
|
102
|
+
const remaining = tasks.filter((t) => !idSet.has(t.id));
|
|
103
|
+
if (remaining.length === tasks.length)
|
|
104
|
+
return;
|
|
105
|
+
await writeCronTasks(remaining, dir);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Stamp `lastFiredAt` on the given recurring tasks and write back.
|
|
109
|
+
*/
|
|
110
|
+
export async function markCronTasksFired(ids, firedAt, dir) {
|
|
111
|
+
if (ids.length === 0)
|
|
112
|
+
return;
|
|
113
|
+
const idSet = new Set(ids);
|
|
114
|
+
const tasks = await readCronTasks(dir);
|
|
115
|
+
let changed = false;
|
|
116
|
+
for (const t of tasks) {
|
|
117
|
+
if (idSet.has(t.id)) {
|
|
118
|
+
t.lastFiredAt = firedAt;
|
|
119
|
+
changed = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!changed)
|
|
123
|
+
return;
|
|
124
|
+
await writeCronTasks(tasks, dir);
|
|
125
|
+
}
|
|
126
|
+
// --- Next-run computation ---
|
|
127
|
+
/**
|
|
128
|
+
* Next fire time in epoch ms for a cron string, strictly after `fromMs`.
|
|
129
|
+
*/
|
|
130
|
+
export function nextCronRunMs(cron, fromMs) {
|
|
131
|
+
const fields = parseCronExpression(cron);
|
|
132
|
+
if (!fields)
|
|
133
|
+
return null;
|
|
134
|
+
const next = computeNextCronRun(fields, new Date(fromMs));
|
|
135
|
+
return next ? next.getTime() : null;
|
|
136
|
+
}
|
|
137
|
+
export const DEFAULT_CRON_JITTER_CONFIG = {
|
|
138
|
+
recurringFrac: 0.1,
|
|
139
|
+
recurringCapMs: 15 * 60 * 1000,
|
|
140
|
+
oneShotMaxMs: 90 * 1000,
|
|
141
|
+
oneShotFloorMs: 0,
|
|
142
|
+
oneShotMinuteMod: 30,
|
|
143
|
+
recurringMaxAgeMs: 7 * 24 * 60 * 60 * 1000,
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Parse taskId (8-hex UUID slice) to a [0, 1) fraction for deterministic jitter.
|
|
147
|
+
*/
|
|
148
|
+
function jitterFrac(taskId) {
|
|
149
|
+
const frac = parseInt(taskId.slice(0, 8), 16) / 0x1_0000_0000;
|
|
150
|
+
return Number.isFinite(frac) ? frac : 0;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Next fire time for recurring tasks, with deterministic forward jitter
|
|
154
|
+
* proportional to the interval between fires.
|
|
155
|
+
*/
|
|
156
|
+
export function jitteredNextCronRunMs(cron, fromMs, taskId, cfg = DEFAULT_CRON_JITTER_CONFIG) {
|
|
157
|
+
const t1 = nextCronRunMs(cron, fromMs);
|
|
158
|
+
if (t1 === null)
|
|
159
|
+
return null;
|
|
160
|
+
const t2 = nextCronRunMs(cron, t1);
|
|
161
|
+
if (t2 === null)
|
|
162
|
+
return t1;
|
|
163
|
+
const jitter = Math.min(jitterFrac(taskId) * cfg.recurringFrac * (t2 - t1), cfg.recurringCapMs);
|
|
164
|
+
return t1 + jitter;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Next fire time for one-shot tasks, with deterministic backward jitter
|
|
168
|
+
* on round minutes (mod oneShotMinuteMod).
|
|
169
|
+
*/
|
|
170
|
+
export function oneShotJitteredNextCronRunMs(cron, fromMs, taskId, cfg = DEFAULT_CRON_JITTER_CONFIG) {
|
|
171
|
+
const t1 = nextCronRunMs(cron, fromMs);
|
|
172
|
+
if (t1 === null)
|
|
173
|
+
return null;
|
|
174
|
+
if (new Date(t1).getMinutes() % cfg.oneShotMinuteMod !== 0)
|
|
175
|
+
return t1;
|
|
176
|
+
const lead = cfg.oneShotFloorMs +
|
|
177
|
+
jitterFrac(taskId) * (cfg.oneShotMaxMs - cfg.oneShotFloorMs);
|
|
178
|
+
return Math.max(t1 - lead, fromMs);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Tasks whose next scheduled run (from createdAt) is in the past.
|
|
182
|
+
*/
|
|
183
|
+
export function findMissedTasks(tasks, nowMs) {
|
|
184
|
+
return tasks.filter((t) => {
|
|
185
|
+
const next = nextCronRunMs(t.cron, t.createdAt);
|
|
186
|
+
return next !== null && next < nowMs;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build a notification message for missed tasks.
|
|
191
|
+
*/
|
|
192
|
+
export function buildMissedTaskNotification(missed) {
|
|
193
|
+
const plural = missed.length > 1;
|
|
194
|
+
const header = `The following one-shot scheduled task${plural ? "s were" : " was"} missed. ` +
|
|
195
|
+
`${plural ? "They have" : "It has"} been removed from the task file.\n\n` +
|
|
196
|
+
`Do NOT execute ${plural ? "these prompts" : "this prompt"} yet. ` +
|
|
197
|
+
`First confirm with the user whether to run ${plural ? "each one" : "it"} now.`;
|
|
198
|
+
const blocks = missed.map((t) => {
|
|
199
|
+
const meta = `[${cronToHuman(t.cron)}, created ${new Date(t.createdAt).toLocaleString()}]`;
|
|
200
|
+
const longestRun = (t.prompt.match(/`+/g) ?? []).reduce((max, run) => Math.max(max, run.length), 0);
|
|
201
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
202
|
+
return `${meta}\n${fence}\n${t.prompt}\n${fence}`;
|
|
203
|
+
});
|
|
204
|
+
return `${header}\n\n${blocks.join("\n\n")}`;
|
|
205
|
+
}
|
package/dist/cron.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal 5-field cron expression parser and next-run calculator.
|
|
3
|
+
*
|
|
4
|
+
* Field syntax: wildcard (*), step ( * /N), range (N-M), range-step (N-M/S),
|
|
5
|
+
* list (N,M,...), plain value (N). Sunday alias: 7 → 0.
|
|
6
|
+
*
|
|
7
|
+
* All times are local timezone. DST-safe: spring-forward gaps are skipped,
|
|
8
|
+
* fall-back duplicates fire once (vixie-cron semantics).
|
|
9
|
+
*/
|
|
10
|
+
export type CronFields = {
|
|
11
|
+
minute: number[];
|
|
12
|
+
hour: number[];
|
|
13
|
+
dayOfMonth: number[];
|
|
14
|
+
month: number[];
|
|
15
|
+
dayOfWeek: number[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Parse a 5-field cron expression into expanded number arrays.
|
|
19
|
+
* Returns null if invalid or unsupported syntax.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseCronExpression(expr: string): CronFields | null;
|
|
22
|
+
/**
|
|
23
|
+
* Compute the next Date strictly after `from` that matches the cron fields.
|
|
24
|
+
* Uses local timezone. Walks forward minute-by-minute, bounded at 366 days.
|
|
25
|
+
*
|
|
26
|
+
* Standard cron: when both dayOfMonth and dayOfWeek are constrained
|
|
27
|
+
* (neither is the full range), a date matches if EITHER matches.
|
|
28
|
+
*/
|
|
29
|
+
export declare function computeNextCronRun(fields: CronFields, from: Date): Date | null;
|
|
30
|
+
/**
|
|
31
|
+
* Convert a cron expression to a human-readable description.
|
|
32
|
+
* Covers common patterns; falls through to the raw string for complex ones.
|
|
33
|
+
*/
|
|
34
|
+
export declare function cronToHuman(cron: string): string;
|
|
35
|
+
//# sourceMappingURL=cron.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cron.d.ts","sourceRoot":"","sources":["../src/cron.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB,CAAC;AA0DF;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAkBnE;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,IAAI,GACT,IAAI,GAAG,IAAI,CAuDb;AAmBD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA2EhD"}
|