@dcrays/scheduled-task 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cron.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export declare const MIN_CRON_INTERVAL_SECONDS = 600;
2
+ interface CronField {
3
+ readonly values: ReadonlySet<number>;
4
+ readonly wildcard: boolean;
5
+ }
6
+ export interface ParsedCronExpression {
7
+ readonly expression: string;
8
+ readonly minutes: CronField;
9
+ readonly hours: CronField;
10
+ readonly daysOfMonth: CronField;
11
+ readonly months: CronField;
12
+ readonly daysOfWeek: CronField;
13
+ }
14
+ export declare class CronExpressionError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ export declare function validTimeZone(timeZone: string): boolean;
18
+ /** Parse a standard five-field minute/hour/day-of-month/month/day-of-week cron expression. */
19
+ export declare function parseCronExpression(value: string): ParsedCronExpression;
20
+ /** Enforce the system-wide minimum cadence for recurring cron schedules. */
21
+ export declare function assertMinimumCronInterval(cron: string | ParsedCronExpression, minimumSeconds?: number): ParsedCronExpression;
22
+ /** Return the first cron occurrence strictly after the supplied instant. */
23
+ export declare function nextCronOccurrence(afterEpochMs: number, cron: string | ParsedCronExpression, timeZone: string): number;
24
+ export {};
package/src/cron.js ADDED
@@ -0,0 +1,211 @@
1
+ const FIELD_COUNT = 5;
2
+ const MAX_SEARCH_DAYS = 366 * 8;
3
+ const GREGORIAN_CYCLE_DAYS = 146_097;
4
+ export const MIN_CRON_INTERVAL_SECONDS = 600;
5
+ export class CronExpressionError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "CronExpressionError";
9
+ }
10
+ }
11
+ export function validTimeZone(timeZone) {
12
+ try {
13
+ new Intl.DateTimeFormat("en-US", { timeZone });
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ function parseInteger(value, label) {
21
+ if (!/^\d+$/.test(value))
22
+ throw new CronExpressionError(`${label} contains a non-integer value`);
23
+ return Number(value);
24
+ }
25
+ function parseField(raw, min, max, label, normalize) {
26
+ const values = new Set();
27
+ const wildcard = raw === "*" || raw.startsWith("*/");
28
+ if (raw.length === 0)
29
+ throw new CronExpressionError(`${label} is empty`);
30
+ for (const segment of raw.split(",")) {
31
+ if (segment.length === 0)
32
+ throw new CronExpressionError(`${label} contains an empty list item`);
33
+ const slash = segment.split("/");
34
+ if (slash.length > 2)
35
+ throw new CronExpressionError(`${label} contains an invalid step`);
36
+ const base = slash[0] ?? "";
37
+ const step = slash[1] === undefined ? 1 : parseInteger(slash[1], label);
38
+ if (step < 1)
39
+ throw new CronExpressionError(`${label} step must be positive`);
40
+ let start;
41
+ let end;
42
+ if (base === "*") {
43
+ start = min;
44
+ end = max;
45
+ }
46
+ else if (base.includes("-")) {
47
+ const range = base.split("-");
48
+ if (range.length !== 2)
49
+ throw new CronExpressionError(`${label} contains an invalid range`);
50
+ start = parseInteger(range[0] ?? "", label);
51
+ end = parseInteger(range[1] ?? "", label);
52
+ }
53
+ else {
54
+ start = parseInteger(base, label);
55
+ end = slash[1] === undefined ? start : max;
56
+ }
57
+ if (start < min || start > max || end < min || end > max || start > end) {
58
+ throw new CronExpressionError(`${label} must stay within ${min}-${max}`);
59
+ }
60
+ for (let value = start; value <= end; value += step)
61
+ values.add(normalize?.(value) ?? value);
62
+ }
63
+ if (values.size === 0)
64
+ throw new CronExpressionError(`${label} selects no values`);
65
+ return { values, wildcard };
66
+ }
67
+ /** Parse a standard five-field minute/hour/day-of-month/month/day-of-week cron expression. */
68
+ export function parseCronExpression(value) {
69
+ if (value.trim() !== value || value.length === 0)
70
+ throw new CronExpressionError("cron expression must not have surrounding whitespace");
71
+ const fields = value.split(/\s+/);
72
+ if (fields.length !== FIELD_COUNT)
73
+ throw new CronExpressionError("cron expression must contain exactly five fields");
74
+ return {
75
+ expression: fields.join(" "),
76
+ minutes: parseField(fields[0] ?? "", 0, 59, "minute"),
77
+ hours: parseField(fields[1] ?? "", 0, 23, "hour"),
78
+ daysOfMonth: parseField(fields[2] ?? "", 1, 31, "day-of-month"),
79
+ months: parseField(fields[3] ?? "", 1, 12, "month"),
80
+ daysOfWeek: parseField(fields[4] ?? "", 0, 7, "day-of-week", (day) => day === 7 ? 0 : day)
81
+ };
82
+ }
83
+ function formatter(timeZone) {
84
+ return new Intl.DateTimeFormat("en-CA-u-ca-iso8601-nu-latn", {
85
+ timeZone,
86
+ year: "numeric",
87
+ month: "2-digit",
88
+ day: "2-digit",
89
+ hour: "2-digit",
90
+ minute: "2-digit",
91
+ second: "2-digit",
92
+ hourCycle: "h23",
93
+ timeZoneName: "longOffset"
94
+ });
95
+ }
96
+ function projectedParts(format, epochMs) {
97
+ const values = Object.fromEntries(format.formatToParts(epochMs).map((part) => [part.type, part.value]));
98
+ const match = /^GMT(?:(?<sign>[+-])(?<hour>\d{2}):(?<minute>\d{2}))?$/.exec(values.timeZoneName ?? "");
99
+ if (!match?.groups)
100
+ throw new CronExpressionError("time zone did not expose a usable UTC offset");
101
+ const direction = match.groups.sign === "-" ? -1 : 1;
102
+ const offset = match.groups.sign === undefined
103
+ ? 0
104
+ : direction * (Number(match.groups.hour) * 60 + Number(match.groups.minute)) * 60_000;
105
+ return {
106
+ year: Number(values.year),
107
+ month: Number(values.month),
108
+ day: Number(values.day),
109
+ hour: Number(values.hour),
110
+ minute: Number(values.minute),
111
+ second: Number(values.second),
112
+ offset
113
+ };
114
+ }
115
+ function localToEpoch(parts, format) {
116
+ const localEpoch = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, 0);
117
+ const offsets = new Set();
118
+ for (const delta of [-86_400_000, 0, 86_400_000])
119
+ offsets.add(projectedParts(format, localEpoch + delta).offset);
120
+ const candidates = [];
121
+ for (const offset of offsets) {
122
+ const candidate = localEpoch - offset;
123
+ const projected = projectedParts(format, candidate);
124
+ if (projected.year === parts.year && projected.month === parts.month && projected.day === parts.day && projected.hour === parts.hour && projected.minute === parts.minute) {
125
+ candidates.push(candidate);
126
+ }
127
+ }
128
+ return [...new Set(candidates)].sort((left, right) => left - right);
129
+ }
130
+ function dateMatches(parsed, year, month, day) {
131
+ if (!parsed.months.values.has(month))
132
+ return false;
133
+ const dayOfWeek = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
134
+ const dom = parsed.daysOfMonth.values.has(day);
135
+ const dow = parsed.daysOfWeek.values.has(dayOfWeek);
136
+ if (parsed.daysOfMonth.wildcard && parsed.daysOfWeek.wildcard)
137
+ return true;
138
+ if (parsed.daysOfMonth.wildcard)
139
+ return dow;
140
+ if (parsed.daysOfWeek.wildcard)
141
+ return dom;
142
+ return dom || dow;
143
+ }
144
+ function canMatchConsecutiveDays(parsed) {
145
+ let date = Date.UTC(2000, 0, 1);
146
+ let previousMatched = false;
147
+ for (let offset = 0; offset < GREGORIAN_CYCLE_DAYS; offset += 1) {
148
+ const calendar = new Date(date);
149
+ const matched = dateMatches(parsed, calendar.getUTCFullYear(), calendar.getUTCMonth() + 1, calendar.getUTCDate());
150
+ if (matched && previousMatched)
151
+ return true;
152
+ previousMatched = matched;
153
+ date += 86_400_000;
154
+ }
155
+ return false;
156
+ }
157
+ /** Enforce the system-wide minimum cadence for recurring cron schedules. */
158
+ export function assertMinimumCronInterval(cron, minimumSeconds = MIN_CRON_INTERVAL_SECONDS) {
159
+ const parsed = typeof cron === "string" ? parseCronExpression(cron) : cron;
160
+ const slots = [...parsed.hours.values]
161
+ .flatMap((hour) => [...parsed.minutes.values].map((minute) => hour * 60 + minute))
162
+ .sort((left, right) => left - right);
163
+ const minimumMinutes = Math.ceil(minimumSeconds / 60);
164
+ for (let index = 1; index < slots.length; index += 1) {
165
+ if ((slots[index] ?? 0) - (slots[index - 1] ?? 0) < minimumMinutes) {
166
+ throw new CronExpressionError(`cron interval must be at least ${minimumSeconds} seconds (${minimumMinutes} minutes)`);
167
+ }
168
+ }
169
+ const first = slots[0];
170
+ const last = slots.at(-1);
171
+ if (first !== undefined
172
+ && last !== undefined
173
+ && first + 1_440 - last < minimumMinutes
174
+ && canMatchConsecutiveDays(parsed)) {
175
+ throw new CronExpressionError(`cron interval must be at least ${minimumSeconds} seconds (${minimumMinutes} minutes)`);
176
+ }
177
+ return parsed;
178
+ }
179
+ /** Return the first cron occurrence strictly after the supplied instant. */
180
+ export function nextCronOccurrence(afterEpochMs, cron, timeZone) {
181
+ if (!validTimeZone(timeZone))
182
+ throw new CronExpressionError("timezone must be a valid IANA time zone");
183
+ const parsed = typeof cron === "string" ? parseCronExpression(cron) : cron;
184
+ const format = formatter(timeZone);
185
+ const start = projectedParts(format, afterEpochMs);
186
+ const hours = [...parsed.hours.values].sort((left, right) => left - right);
187
+ const minutes = [...parsed.minutes.values].sort((left, right) => left - right);
188
+ let date = Date.UTC(start.year, start.month - 1, start.day);
189
+ for (let offset = 0; offset < MAX_SEARCH_DAYS; offset += 1) {
190
+ const calendar = new Date(date);
191
+ const year = calendar.getUTCFullYear();
192
+ const month = calendar.getUTCMonth() + 1;
193
+ const day = calendar.getUTCDate();
194
+ if (dateMatches(parsed, year, month, day)) {
195
+ let earliest;
196
+ for (const hour of hours) {
197
+ for (const minute of minutes) {
198
+ const candidates = localToEpoch({ year, month, day, hour, minute }, format);
199
+ for (const candidate of candidates) {
200
+ if (candidate > afterEpochMs && (earliest === undefined || candidate < earliest))
201
+ earliest = candidate;
202
+ }
203
+ }
204
+ }
205
+ if (earliest !== undefined)
206
+ return earliest;
207
+ }
208
+ date = Date.UTC(year, month - 1, day + 1);
209
+ }
210
+ throw new CronExpressionError("cron expression has no occurrence within the next eight years");
211
+ }
@@ -0,0 +1,51 @@
1
+ import type { CronCreateEnvelope } from './protocol.js';
2
+ import { CronAutomationRepository, type StoredCronAutomation } from './repository.js';
3
+ export interface CronAutomationManagerOptions {
4
+ maxPromptChars: number;
5
+ now?: () => number;
6
+ deliver(job: StoredCronAutomation, occurrenceAt: string): boolean | Promise<boolean>;
7
+ onError?(error: unknown): void;
8
+ onCreated?(job: StoredCronAutomation): void;
9
+ onChanged?(job: StoredCronAutomation): void;
10
+ onDispatched?(job: StoredCronAutomation, occurrenceAt: string): void;
11
+ }
12
+ export interface CronAutomationPatch {
13
+ enabled?: boolean;
14
+ name?: string;
15
+ prompt?: string;
16
+ cron?: string;
17
+ timezone?: string;
18
+ sessionKey?: string;
19
+ agentId?: string;
20
+ dshSessionId?: string;
21
+ }
22
+ export type CronRunStatus = 'dispatched' | 'not_found' | 'already_running' | 'delivery_unavailable';
23
+ export declare class CronAutomationManager {
24
+ private readonly repository;
25
+ private readonly options;
26
+ private readonly jobs;
27
+ private readonly blockedAgents;
28
+ private readonly now;
29
+ private started?;
30
+ private tail;
31
+ private timer;
32
+ private stopping;
33
+ constructor(repository: CronAutomationRepository, options: CronAutomationManagerOptions);
34
+ start(): Promise<void>;
35
+ private initialize;
36
+ stop(): Promise<void>;
37
+ create(agentId: string, envelope: CronCreateEnvelope): Promise<StoredCronAutomation>;
38
+ update(jobId: string, patch: CronAutomationPatch): Promise<StoredCronAutomation | undefined>;
39
+ delete(jobId: string): Promise<boolean>;
40
+ runOnceStatus(jobId: string): Promise<CronRunStatus>;
41
+ runOnce(jobId: string): Promise<boolean>;
42
+ list(agentId?: string): Promise<StoredCronAutomation[]>;
43
+ notifyAgentAvailable(agentId: string): void;
44
+ /** Import each source job once. Existing ids win so DSH bindings stay intact. */
45
+ importFrom(source: string, incoming: readonly StoredCronAutomation[]): Promise<StoredCronAutomation[]>;
46
+ private deliver;
47
+ private enqueue;
48
+ private requestDispatch;
49
+ private arm;
50
+ private dispatchDue;
51
+ }