@qilin-agent/schedule 0.0.0-stage → 3.1.4

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.
@@ -0,0 +1,1587 @@
1
+ /**
2
+ * Strict Schedule decoding, replay, time validation, and framing.
3
+ * @module @qilin-agent/schedule
4
+ */
5
+ import { Temporal } from '@js-temporal/polyfill';
6
+ import { SessionLogOffset } from '@qilin-agent/session';
7
+ /** Durable Schedule protocol version implemented by this package. */
8
+ export const SCHEDULE_CHANGE_VERSION = 1;
9
+ /** Fixed v1 lower bound for a fixed-rate reminder. */
10
+ export const MIN_EVERY_INTERVAL_SECONDS = 60;
11
+ /** Fixed v1 upper bound for a stored task title. */
12
+ export const MAX_TITLE_LENGTH = 120;
13
+ /**
14
+ * Longest forward or backward walk of the cron date search, in years.
15
+ *
16
+ * The proleptic Gregorian leap-year and weekday alignment repeats every 400
17
+ * years, so a rule that matches any local date has a match within this window;
18
+ * walking further can only reach a rule that never matches. Bounding the walk
19
+ * keeps a valid but unsatisfiable rule's creation and decision cost fixed
20
+ * instead of enumerating candidate dates toward the four-digit-year ceiling.
21
+ */
22
+ export const CRON_SEARCH_HORIZON_YEARS = 400;
23
+ const MIN_FOUR_DIGIT_YEAR_MS = Date.parse('0001-01-01T00:00:00.000Z');
24
+ const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z');
25
+ const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/;
26
+ const OFFSET_INSTANT = new RegExp(String.raw `^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})`
27
+ + String.raw `T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})`
28
+ + String.raw `(?:\.(?<fraction>\d{1,3}))?(?<zone>Z|(?<sign>[+-])`
29
+ + String.raw `(?<offsetHour>\d{2}):(?<offsetMinute>\d{2}))$`);
30
+ const LOCAL_DATE = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;
31
+ const LOCAL_TIME = /^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?:\.(?<fraction>\d{1,3}))?$/;
32
+ const LOCAL_CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?$/;
33
+ const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
34
+ /** Error from malformed or transition-invalid durable Schedule data. */
35
+ export class ScheduleLogError extends Error {
36
+ /** Stable machine-readable error code. */
37
+ code = 'corrupt_schedule_log';
38
+ /**
39
+ * Construct a durable-log failure.
40
+ * @param message - Package-specific violated invariant.
41
+ */
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = 'ScheduleLogError';
45
+ }
46
+ }
47
+ /** Error from a model-supplied Schedule rule that cannot become a record. */
48
+ export class ScheduleInputError extends Error {
49
+ /** Stable public Schedule input code. */
50
+ code;
51
+ /**
52
+ * Construct a stable input failure.
53
+ * @param code - Public Schedule error discriminator.
54
+ * @param message - Stable public diagnostic.
55
+ * @param options - Optional contained implementation cause.
56
+ */
57
+ constructor(code, message, options) {
58
+ super(message, options);
59
+ this.name = 'ScheduleInputError';
60
+ this.code = code;
61
+ }
62
+ }
63
+ /**
64
+ * Brand a raw session-local id without changing its runtime value.
65
+ * @param value - Raw session-local id.
66
+ * @returns The same string with the Schedule brand.
67
+ */
68
+ export function ScheduleId(value) {
69
+ return value;
70
+ }
71
+ /** Whether an unknown value is a non-array object. */
72
+ function isRecord(value) {
73
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
74
+ }
75
+ /** Require exactly the named durable object keys. */
76
+ function hasExactKeys(value, expected) {
77
+ const allowed = new Set(expected);
78
+ return expected.every(key => key in value) && Object.keys(value).every(key => allowed.has(key));
79
+ }
80
+ /** Require the named durable keys while admitting further optional members. */
81
+ function hasExactKeysWithOptional(value, required, optional) {
82
+ const allowed = new Set([...required, ...optional]);
83
+ return required.every(key => key in value) && Object.keys(value).every(key => allowed.has(key));
84
+ }
85
+ /** Stable diagnostic for a title that is missing or empty after trimming. */
86
+ export const REQUIRED_TITLE_MESSAGE = 'title is required and must be non-empty after trimming.';
87
+ /**
88
+ * Validate the title supplied at creation.
89
+ *
90
+ * Creation requires an explicit title: a missing, blank-after-trim, or over-long
91
+ * value throws instead of deriving a name from the instruction.
92
+ * @param title - Task name supplied at creation.
93
+ * @returns The trimmed title; an invalid title throws ScheduleInputError.
94
+ */
95
+ export function scheduleTitle(title) {
96
+ if (typeof title !== 'string' || title.trim().length === 0) {
97
+ throw new ScheduleInputError('invalid_prompt', REQUIRED_TITLE_MESSAGE);
98
+ }
99
+ const normalized = title.trim();
100
+ if (normalized.length > MAX_TITLE_LENGTH) {
101
+ throw new ScheduleInputError('invalid_prompt', `title must be at most ${MAX_TITLE_LENGTH} characters.`);
102
+ }
103
+ return normalized;
104
+ }
105
+ /**
106
+ * Validate one required stored title at the durable boundary.
107
+ *
108
+ * Only records written after titles became required are read: a missing,
109
+ * blank-after-trim, untrimmed, or over-long stored title is invalid, and no name
110
+ * is derived from the instruction.
111
+ * @param value - Untrusted durable title field.
112
+ * @returns The stored title; an invalid title throws ScheduleLogError.
113
+ */
114
+ export function decodeStoredTitle(value) {
115
+ if (typeof value !== 'string' || value.trim().length === 0) {
116
+ throw new ScheduleLogError(REQUIRED_TITLE_MESSAGE);
117
+ }
118
+ if (value.length > MAX_TITLE_LENGTH) {
119
+ throw new ScheduleLogError(`title must be at most ${MAX_TITLE_LENGTH} characters`);
120
+ }
121
+ if (value.trim() !== value) {
122
+ throw new ScheduleLogError('title must be a trimmed string');
123
+ }
124
+ return value;
125
+ }
126
+ /**
127
+ * Decode the required stored title of one durable Host record, then require its exact key set.
128
+ *
129
+ * The title decodes first so that a record without the key reports the title
130
+ * diagnostic rather than the record's required-key list.
131
+ * @param value - Untrusted durable record already known to be an object.
132
+ * @param expected - Exact keys the record must carry, including `title`.
133
+ * @param message - Diagnostic naming the record's required keys.
134
+ * @returns The stored title; an invalid title or key set throws ScheduleLogError.
135
+ */
136
+ function decodeRecordTitle(value, expected, message) {
137
+ const title = decodeStoredTitle(value['title']);
138
+ if (!hasExactKeys(value, expected))
139
+ throw new ScheduleLogError(message);
140
+ return title;
141
+ }
142
+ /**
143
+ * Decode the stored title of one historical `schedule/change` create record.
144
+ *
145
+ * A version-1 event written before names existed has no `title` member, so the
146
+ * key set is required without it and the absent member decodes as undefined. A
147
+ * present title stays subject to the canonical stored form.
148
+ * @param value - Untrusted durable record already known to be an object.
149
+ * @param expected - Exact keys the record may carry; `title` is optional within them.
150
+ * @param message - Diagnostic naming the record's keys.
151
+ * @returns The stored title, or undefined when the historical record predates it.
152
+ */
153
+ function decodeHistoricalRecordTitle(value, expected, message) {
154
+ const keys = expected.filter(key => key !== 'title');
155
+ if (!hasExactKeysWithOptional(value, keys, ['title']))
156
+ throw new ScheduleLogError(message);
157
+ return value['title'] === undefined ? undefined : decodeStoredTitle(value['title']);
158
+ }
159
+ /** Validate one stable session-local id at the durable boundary. */
160
+ function decodeId(value) {
161
+ if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) {
162
+ throw new ScheduleLogError('schedule id must be a non-empty string without surrounding whitespace');
163
+ }
164
+ return ScheduleId(value);
165
+ }
166
+ /** Validate one canonical four-digit-year UTC instant. */
167
+ function decodeInstant(value) {
168
+ if (typeof value !== 'string' || !UTC_INSTANT.test(value)) {
169
+ throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant');
170
+ }
171
+ const epoch = Date.parse(value);
172
+ if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) {
173
+ throw new ScheduleLogError('scheduledAt is not a real UTC calendar instant');
174
+ }
175
+ return value;
176
+ }
177
+ /** Read one required named regular-expression group as a number. */
178
+ function groupNumber(groups, name) {
179
+ const value = groups[name];
180
+ /* v8 ignore next -- successful fixed regexes always provide every requested group. */
181
+ if (value === undefined)
182
+ throw new ScheduleInputError('invalid_rule', 'The at value has an invalid shape.');
183
+ return Number(value);
184
+ }
185
+ /** Convert exact calendar fields to a UTC-shaped epoch while rejecting normalization. */
186
+ function calendarEpoch(parts) {
187
+ const value = new Date(0);
188
+ value.setUTCHours(0, 0, 0, 0);
189
+ value.setUTCFullYear(parts.year, parts.month - 1, parts.day);
190
+ value.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
191
+ const epoch = value.getTime();
192
+ if (!Number.isFinite(epoch)
193
+ || value.getUTCFullYear() !== parts.year
194
+ || value.getUTCMonth() + 1 !== parts.month
195
+ || value.getUTCDate() !== parts.day
196
+ || value.getUTCHours() !== parts.hour
197
+ || value.getUTCMinutes() !== parts.minute
198
+ || value.getUTCSeconds() !== parts.second
199
+ || value.getUTCMilliseconds() !== parts.millisecond) {
200
+ throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.');
201
+ }
202
+ return epoch;
203
+ }
204
+ /** Normalize an optional one-to-three digit fractional second to milliseconds. */
205
+ function milliseconds(value) {
206
+ return value === undefined ? 0 : Number(value.padEnd(3, '0'));
207
+ }
208
+ /** Require a safe, representable, strictly future UTC target. */
209
+ function futureInstant(epoch, now) {
210
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(epoch)
211
+ || epoch < MIN_FOUR_DIGIT_YEAR_MS || epoch > MAX_FOUR_DIGIT_YEAR_MS) {
212
+ throw new ScheduleInputError('time_out_of_range', 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.');
213
+ }
214
+ if (epoch <= now) {
215
+ throw new ScheduleInputError('not_future', 'The scheduled time must be strictly in the future.');
216
+ }
217
+ const instant = new Date(epoch).toISOString();
218
+ /* v8 ignore next -- an in-range integral Date always formats as the canonical UTC profile. */
219
+ if (!UTC_INSTANT.test(instant)) {
220
+ throw new ScheduleInputError('time_out_of_range', 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.');
221
+ }
222
+ return instant;
223
+ }
224
+ /** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */
225
+ function parseOffsetInstant(value) {
226
+ const match = OFFSET_INSTANT.exec(value);
227
+ const groups = match?.groups;
228
+ if (groups === undefined) {
229
+ throw new ScheduleInputError('invalid_rule', 'at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.');
230
+ }
231
+ const parts = {
232
+ year: groupNumber(groups, 'year'),
233
+ month: groupNumber(groups, 'month'),
234
+ day: groupNumber(groups, 'day'),
235
+ hour: groupNumber(groups, 'hour'),
236
+ minute: groupNumber(groups, 'minute'),
237
+ second: groupNumber(groups, 'second'),
238
+ millisecond: milliseconds(groups['fraction']),
239
+ };
240
+ if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) {
241
+ throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.');
242
+ }
243
+ const localEpoch = calendarEpoch(parts);
244
+ if (groups['zone'] === 'Z')
245
+ return localEpoch;
246
+ const offsetHour = groupNumber(groups, 'offsetHour');
247
+ const offsetMinute = groupNumber(groups, 'offsetMinute');
248
+ if (offsetHour > 23 || offsetMinute > 59
249
+ || (groups['sign'] === '-' && offsetHour === 0 && offsetMinute === 0)) {
250
+ throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.');
251
+ }
252
+ const direction = groups['sign'] === '+' ? 1 : -1;
253
+ return localEpoch - direction * (offsetHour * 60 + offsetMinute) * 60_000;
254
+ }
255
+ /**
256
+ * Validate and canonicalize one raw IANA time-zone selector.
257
+ * @param value - Candidate `UTC` or IANA Area/Location name.
258
+ * @returns The runtime's canonical IANA name.
259
+ */
260
+ export function canonicalizeTimeZone(value) {
261
+ if (value.length === 0 || value.trim() !== value || (value !== 'UTC' && !IANA_ZONE.test(value))) {
262
+ throw new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.');
263
+ }
264
+ let canonical;
265
+ try {
266
+ canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
267
+ }
268
+ catch (error) {
269
+ throw new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.', { cause: error });
270
+ }
271
+ /* v8 ignore next -- Intl returns the requested canonical zone or an IANA canonical alias. */
272
+ if (canonical !== 'UTC' && !IANA_ZONE.test(canonical)) {
273
+ throw new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.');
274
+ }
275
+ return canonical;
276
+ }
277
+ /** Parse strict local calendar fields without consulting a process time zone. */
278
+ function parseLocalAt(value) {
279
+ const dateMatch = LOCAL_DATE.exec(value.date);
280
+ const timeMatch = LOCAL_TIME.exec(value.time);
281
+ const date = dateMatch?.groups;
282
+ const time = timeMatch?.groups;
283
+ if (date === undefined || time === undefined) {
284
+ throw new ScheduleInputError('invalid_rule', 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.');
285
+ }
286
+ const parts = {
287
+ year: groupNumber(date, 'year'),
288
+ month: groupNumber(date, 'month'),
289
+ day: groupNumber(date, 'day'),
290
+ hour: groupNumber(time, 'hour'),
291
+ minute: groupNumber(time, 'minute'),
292
+ second: groupNumber(time, 'second'),
293
+ millisecond: milliseconds(time['fraction']),
294
+ };
295
+ if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) {
296
+ throw new ScheduleInputError('invalid_rule', 'The local at value must be a real ISO calendar date and time.');
297
+ }
298
+ calendarEpoch(parts);
299
+ return parts;
300
+ }
301
+ /** Resolve only the earlier overlap instant; a gap fails the local-field round trip. */
302
+ function localInstant(local, timeZone) {
303
+ const zoned = local.toZonedDateTime(timeZone, { disambiguation: 'earlier' });
304
+ return zoned.toPlainDateTime().equals(local) ? zoned.epochMilliseconds : undefined;
305
+ }
306
+ /** Resolve a local one-shot, rejecting nonexistent wall-clock times. */
307
+ function resolveLocalInstant(parts, timeZone) {
308
+ const target = localInstant(Temporal.PlainDateTime.from(parts, { overflow: 'reject' }), timeZone);
309
+ if (target === undefined) {
310
+ throw new ScheduleInputError('invalid_rule', 'The local at time does not exist in the selected time zone.');
311
+ }
312
+ return target;
313
+ }
314
+ /** Decode the exact v1 after record shape. */
315
+ function decodeAfterRecord(value) {
316
+ /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
317
+ if (!isRecord(value))
318
+ throw new ScheduleLogError('after schedule must be an object');
319
+ const title = decodeHistoricalRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'afterSeconds', 'scheduledAt'], 'after schedule must contain exactly id, kind, title, prompt, afterSeconds, and scheduledAt');
320
+ const prompt = value['prompt'];
321
+ if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
322
+ throw new ScheduleLogError('after prompt must be non-empty and already trimmed');
323
+ }
324
+ const afterSeconds = value['afterSeconds'];
325
+ if (!Number.isSafeInteger(afterSeconds) || afterSeconds <= 0) {
326
+ throw new ScheduleLogError('afterSeconds must be a positive safe integer');
327
+ }
328
+ return Object.freeze({
329
+ id: decodeId(value['id']),
330
+ kind: 'after',
331
+ ...(title === undefined ? {} : { title }),
332
+ prompt,
333
+ afterSeconds: afterSeconds,
334
+ scheduledAt: decodeInstant(value['scheduledAt']),
335
+ });
336
+ }
337
+ /** Decode the exact v1 absolute one-shot record shape. */
338
+ function decodeAtRecord(value) {
339
+ /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
340
+ if (!isRecord(value))
341
+ throw new ScheduleLogError('at schedule must be an object');
342
+ const title = decodeHistoricalRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'scheduledAt'], 'at schedule must contain exactly id, kind, title, prompt, and scheduledAt');
343
+ const prompt = value['prompt'];
344
+ if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
345
+ throw new ScheduleLogError('at prompt must be non-empty and already trimmed');
346
+ }
347
+ return Object.freeze({
348
+ id: decodeId(value['id']),
349
+ kind: 'at',
350
+ ...(title === undefined ? {} : { title }),
351
+ prompt,
352
+ scheduledAt: decodeInstant(value['scheduledAt']),
353
+ });
354
+ }
355
+ /** Decode the exact v1 fixed-rate record shape. */
356
+ function decodeEveryRecord(value) {
357
+ /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
358
+ if (!isRecord(value))
359
+ throw new ScheduleLogError('every schedule must be an object');
360
+ const title = decodeHistoricalRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'everySeconds', 'scheduledAt'], 'every schedule must contain exactly id, kind, title, prompt, everySeconds, and scheduledAt');
361
+ const prompt = value['prompt'];
362
+ if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
363
+ throw new ScheduleLogError('every prompt must be non-empty and already trimmed');
364
+ }
365
+ const everySeconds = value['everySeconds'];
366
+ const interval = typeof everySeconds === 'number' ? everySeconds * 1_000 : Number.NaN;
367
+ if (!Number.isSafeInteger(everySeconds)
368
+ || everySeconds < MIN_EVERY_INTERVAL_SECONDS
369
+ || !Number.isSafeInteger(interval)) {
370
+ throw new ScheduleLogError(`everySeconds must be a safe integer of at least ${MIN_EVERY_INTERVAL_SECONDS}`);
371
+ }
372
+ return Object.freeze({
373
+ id: decodeId(value['id']),
374
+ kind: 'every',
375
+ ...(title === undefined ? {} : { title }),
376
+ prompt,
377
+ everySeconds: everySeconds,
378
+ scheduledAt: decodeInstant(value['scheduledAt']),
379
+ });
380
+ }
381
+ /**
382
+ * Parse one strict local clock time shared by the wall-clock selectors.
383
+ * @param value - Candidate `HH:mm:ss` time with optional one-to-three fractional digits.
384
+ * @param selector - Public selector name used in the diagnostic.
385
+ * @returns The parsed plain time; malformed input throws ScheduleInputError.
386
+ */
387
+ function localClockTime(value, selector) {
388
+ if (!LOCAL_CLOCK_TIME.test(value)) {
389
+ throw new ScheduleInputError('invalid_rule', `${selector}.time must use HH:mm:ss with optional 1-3 fractional digits, without leap seconds or 24:00.`);
390
+ }
391
+ return Temporal.PlainTime.from(value);
392
+ }
393
+ /** Parse strictly before Temporal can constrain or coerce the supplied time. */
394
+ function dailyTime(value) {
395
+ return localClockTime(value, 'daily');
396
+ }
397
+ /**
398
+ * Parse one strict local clock time accepted by the weekly selector.
399
+ * @param value - Candidate `HH:mm:ss` time with optional one-to-three fractional digits.
400
+ * @returns The parsed plain time; malformed input throws ScheduleInputError.
401
+ */
402
+ export function weeklyTime(value) {
403
+ return localClockTime(value, 'weekly');
404
+ }
405
+ /** Fail already-typed durable values without wrapping the stored-data diagnostic. */
406
+ function rethrowLogError(error) {
407
+ /* v8 ignore next -- every wrapped call raises ScheduleInputError, so this pass-through never fires. */
408
+ if (error instanceof ScheduleLogError)
409
+ throw error;
410
+ throw new ScheduleLogError(String(error));
411
+ }
412
+ /** Decode a Host daily rule without re-resolving its committed UTC target. */
413
+ function decodeDailyRecord(value) {
414
+ const title = decodeRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'time', 'timeZone', 'scheduledAt'], 'daily schedule must contain exactly id, kind, title, prompt, time, timeZone, and scheduledAt');
415
+ const prompt = value['prompt'];
416
+ if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
417
+ throw new ScheduleLogError('daily prompt must be non-empty and already trimmed');
418
+ }
419
+ const time = value['time'];
420
+ const timeZone = value['timeZone'];
421
+ if (typeof time !== 'string' || typeof timeZone !== 'string') {
422
+ throw new ScheduleLogError('daily time and timeZone must be strings');
423
+ }
424
+ let normalized;
425
+ try {
426
+ normalized = dailyTime(time).toString({ fractionalSecondDigits: 3 });
427
+ canonicalizeTimeZone(timeZone);
428
+ }
429
+ catch (error) {
430
+ rethrowLogError(error);
431
+ }
432
+ if (time !== normalized)
433
+ throw new ScheduleLogError('daily time must be normalized to HH:mm:ss.SSS');
434
+ return Object.freeze({
435
+ id: decodeId(value['id']), kind: 'daily', title, prompt, time, timeZone,
436
+ scheduledAt: decodeInstant(value['scheduledAt']),
437
+ });
438
+ }
439
+ /**
440
+ * Normalize the explicit ISO weekday set of one weekly rule.
441
+ * @param weekdays - Untrusted candidate weekday values.
442
+ * @returns Frozen unique ascending weekdays from 1 (Monday) through 7 (Sunday).
443
+ */
444
+ export function normalizeWeekdays(weekdays) {
445
+ if (!Array.isArray(weekdays) || weekdays.length === 0) {
446
+ throw new ScheduleInputError('invalid_rule', 'weekly.weekdays must be a non-empty array of ISO weekday numbers.');
447
+ }
448
+ const unique = new Set();
449
+ for (const weekday of weekdays) {
450
+ if (typeof weekday !== 'number' || !Number.isInteger(weekday) || weekday < 1 || weekday > 7) {
451
+ throw new ScheduleInputError('invalid_rule', 'Each weekly.weekdays entry must be an integer from 1 (Monday) through 7 (Sunday).');
452
+ }
453
+ if (unique.has(weekday)) {
454
+ throw new ScheduleInputError('invalid_rule', `weekly.weekdays must not repeat weekday ${weekday}.`);
455
+ }
456
+ unique.add(weekday);
457
+ }
458
+ const ordered = [...unique].sort((left, right) => left - right);
459
+ Object.freeze(ordered);
460
+ return ordered;
461
+ }
462
+ /**
463
+ * Decode a Host weekly rule without re-resolving its committed UTC target.
464
+ * @param value - Untrusted durable record already identified as weekly.
465
+ * @returns Detached frozen weekly record; malformed fields throw ScheduleLogError.
466
+ */
467
+ function decodeWeeklyRecord(value) {
468
+ const title = decodeRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'time', 'timeZone', 'weekdays', 'scheduledAt'], 'weekly schedule must contain exactly id, kind, title, prompt, time, timeZone, weekdays, and scheduledAt');
469
+ const prompt = value['prompt'];
470
+ if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
471
+ throw new ScheduleLogError('weekly prompt must be non-empty and already trimmed');
472
+ }
473
+ const time = value['time'];
474
+ const timeZone = value['timeZone'];
475
+ if (typeof time !== 'string' || typeof timeZone !== 'string') {
476
+ throw new ScheduleLogError('weekly time and timeZone must be strings');
477
+ }
478
+ try {
479
+ canonicalizeTimeZone(timeZone);
480
+ }
481
+ catch (error) {
482
+ rethrowLogError(error);
483
+ }
484
+ let normalized;
485
+ try {
486
+ normalized = weeklyTime(time).toString({ fractionalSecondDigits: 3 });
487
+ }
488
+ catch (error) {
489
+ rethrowLogError(error);
490
+ }
491
+ if (time !== normalized)
492
+ throw new ScheduleLogError('weekly time must be normalized to HH:mm:ss.SSS');
493
+ // normalizeWeekdays proves the field is a number array before this check compares it to the decoded copy.
494
+ const stored = value['weekdays'];
495
+ let weekdays;
496
+ try {
497
+ weekdays = normalizeWeekdays(stored);
498
+ }
499
+ catch (error) {
500
+ rethrowLogError(error);
501
+ }
502
+ if (weekdays.some((weekday, index) => weekday !== stored[index])) {
503
+ throw new ScheduleLogError('weekly weekdays must be normalized to unique ascending ISO weekday numbers');
504
+ }
505
+ return Object.freeze({
506
+ id: decodeId(value['id']), kind: 'weekly', title, prompt, time, timeZone,
507
+ weekdays, scheduledAt: decodeInstant(value['scheduledAt']),
508
+ });
509
+ }
510
+ /**
511
+ * Decode a Host cron rule without re-resolving its committed UTC target.
512
+ * @param value - Untrusted durable record already identified as cron.
513
+ * @returns Detached frozen cron record; malformed fields throw ScheduleLogError.
514
+ */
515
+ function decodeCronRecord(value) {
516
+ const title = decodeRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'expression', 'timeZone', 'scheduledAt'], 'cron schedule must contain exactly id, kind, title, prompt, expression, timeZone, and scheduledAt');
517
+ const prompt = value['prompt'];
518
+ if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
519
+ throw new ScheduleLogError('cron prompt must be non-empty and already trimmed');
520
+ }
521
+ const expression = value['expression'];
522
+ const timeZone = value['timeZone'];
523
+ if (typeof expression !== 'string' || typeof timeZone !== 'string') {
524
+ throw new ScheduleLogError('cron expression and timeZone must be strings');
525
+ }
526
+ try {
527
+ canonicalizeTimeZone(timeZone);
528
+ }
529
+ catch (error) {
530
+ rethrowLogError(error);
531
+ }
532
+ let canonical;
533
+ try {
534
+ canonical = parseCronExpression(expression).expression;
535
+ }
536
+ catch (error) {
537
+ rethrowLogError(error);
538
+ }
539
+ if (expression !== canonical)
540
+ throw new ScheduleLogError('cron expression must be canonical');
541
+ return Object.freeze({
542
+ id: decodeId(value['id']), kind: 'cron', title, prompt, expression, timeZone,
543
+ scheduledAt: decodeInstant(value['scheduledAt']),
544
+ });
545
+ }
546
+ /**
547
+ * Decode a current Host task record, preserving its committed target and stored zone spelling.
548
+ *
549
+ * Every variant of a stored Host task carries its title, so the historical
550
+ * one-shot variants are re-checked for that member after their shape decodes.
551
+ * @param value - Untrusted durable JSON record.
552
+ * @returns Detached frozen record; malformed fields throw ScheduleLogError.
553
+ */
554
+ export function decodeScheduleRecord(value) {
555
+ if (isRecord(value) && value['kind'] === 'daily')
556
+ return decodeDailyRecord(value);
557
+ if (isRecord(value) && value['kind'] === 'weekly')
558
+ return decodeWeeklyRecord(value);
559
+ if (isRecord(value) && value['kind'] === 'cron')
560
+ return decodeCronRecord(value);
561
+ const record = decodeLegacyScheduleRecord(value);
562
+ if (record.title === undefined)
563
+ throw new ScheduleLogError(REQUIRED_TITLE_MESSAGE);
564
+ return record;
565
+ }
566
+ /**
567
+ * Decode only the variants admitted by historical version-1 Session events.
568
+ *
569
+ * A record written before titles existed decodes without that member, so the
570
+ * fold keeps reading a log that the current creation path could not write.
571
+ * @param value - Untrusted durable JSON record from a Session event.
572
+ * @returns Detached frozen record, without a title when the event omitted it.
573
+ */
574
+ function decodeLegacyScheduleRecord(value) {
575
+ if (!isRecord(value))
576
+ throw new ScheduleLogError('schedule record must be an object');
577
+ switch (value['kind']) {
578
+ case 'after': return decodeAfterRecord(value);
579
+ case 'at': return decodeAtRecord(value);
580
+ case 'every': return decodeEveryRecord(value);
581
+ default: throw new ScheduleLogError('v1 schedule kind must be "after", "at", or "every"');
582
+ }
583
+ }
584
+ /**
585
+ * Decode one strict version-1 `schedule/change` payload.
586
+ * @param value - Untrusted durable JSON value.
587
+ * @returns Detached, frozen Schedule change.
588
+ */
589
+ export function decodeScheduleChange(value) {
590
+ if (!isRecord(value))
591
+ throw new ScheduleLogError('schedule/change payload must be an object');
592
+ if (value['version'] !== SCHEDULE_CHANGE_VERSION) {
593
+ throw new ScheduleLogError('schedule/change version must be 1');
594
+ }
595
+ switch (value['operation']) {
596
+ case 'create':
597
+ if (!hasExactKeys(value, ['version', 'operation', 'schedule'])) {
598
+ throw new ScheduleLogError('schedule create must contain exactly version, operation, and schedule');
599
+ }
600
+ return Object.freeze({
601
+ version: SCHEDULE_CHANGE_VERSION,
602
+ operation: 'create',
603
+ schedule: decodeLegacyScheduleRecord(value['schedule']),
604
+ });
605
+ case 'delete': {
606
+ if (!hasExactKeys(value, ['version', 'operation', 'id'])) {
607
+ throw new ScheduleLogError('schedule delete must contain exactly version, operation, and id');
608
+ }
609
+ return Object.freeze({
610
+ version: SCHEDULE_CHANGE_VERSION,
611
+ operation: 'delete',
612
+ id: decodeId(value['id']),
613
+ });
614
+ }
615
+ case 'dispatch': {
616
+ if (hasExactKeys(value, ['version', 'operation', 'id'])) {
617
+ return Object.freeze({
618
+ version: SCHEDULE_CHANGE_VERSION,
619
+ operation: 'dispatch',
620
+ id: decodeId(value['id']),
621
+ });
622
+ }
623
+ if (hasExactKeys(value, ['version', 'operation', 'id', 'acceptedAt'])) {
624
+ return Object.freeze({
625
+ version: SCHEDULE_CHANGE_VERSION,
626
+ operation: 'dispatch',
627
+ id: decodeId(value['id']),
628
+ acceptedAt: decodeInstant(value['acceptedAt']),
629
+ });
630
+ }
631
+ throw new ScheduleLogError('schedule dispatch must contain id and optional acceptedAt only');
632
+ }
633
+ default:
634
+ throw new ScheduleLogError('schedule/change operation must be create, delete, or dispatch');
635
+ }
636
+ }
637
+ /**
638
+ * Resolve one fixed-rate decision without enumerating missed occurrences.
639
+ * @param record - Active record whose target is the earliest unaccepted occurrence.
640
+ * @param acceptedAt - Wall-clock decision time in epoch milliseconds.
641
+ * @returns The latest due occurrence and first strictly future target, if representable.
642
+ */
643
+ export function resolveEveryOccurrence(record, acceptedAt) {
644
+ const target = Date.parse(record.scheduledAt);
645
+ const interval = record.everySeconds * 1_000;
646
+ if (!Number.isSafeInteger(acceptedAt)
647
+ || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS
648
+ || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) {
649
+ throw new ScheduleLogError('every acceptedAt must be a representable four-digit-year instant');
650
+ }
651
+ if (!Number.isSafeInteger(interval) || interval <= 0) {
652
+ throw new ScheduleLogError('every interval milliseconds must be a positive safe integer');
653
+ }
654
+ if (acceptedAt < target) {
655
+ throw new ScheduleLogError('every dispatch cannot precede the active scheduledAt');
656
+ }
657
+ const steps = Math.floor((acceptedAt - target) / interval);
658
+ const occurrence = target + steps * interval;
659
+ /* v8 ignore next -- bounded operands and a quotient-derived product stay safe. */
660
+ if (!Number.isSafeInteger(occurrence) || occurrence < target || occurrence > acceptedAt) {
661
+ throw new ScheduleLogError('every occurrence arithmetic must stay within the accepted interval');
662
+ }
663
+ const occurrenceAt = new Date(occurrence).toISOString();
664
+ const next = occurrence + interval;
665
+ if (!Number.isSafeInteger(next) || next > MAX_FOUR_DIGIT_YEAR_MS) {
666
+ return Object.freeze({ occurrenceAt });
667
+ }
668
+ return Object.freeze({
669
+ occurrenceAt,
670
+ nextScheduledAt: new Date(next).toISOString(),
671
+ });
672
+ }
673
+ /** Project an explicit instant into a calendar date in the rule's zone. */
674
+ function localDate(epoch, timeZone) {
675
+ return Temporal.Instant.fromEpochMilliseconds(epoch).toZonedDateTimeISO(timeZone).toPlainDate();
676
+ }
677
+ /** Skip every local date; used by a rule that selects by time alone. */
678
+ const EVERY_DATE = () => true;
679
+ /** Select the explicit ISO weekdays of one normalized weekly rule. */
680
+ function weekdaySet(weekdays) {
681
+ const selected = new Set(weekdays);
682
+ return date => selected.has(date.dayOfWeek);
683
+ }
684
+ /** Find the first actual occurrence after now and, when supplied, after a delivered date. */
685
+ function nextMatchingTarget(time, timeZone, now, matches, afterDate) {
686
+ let date = localDate(now, timeZone);
687
+ const lastDate = localDate(MAX_FOUR_DIGIT_YEAR_MS, 'UTC').add({ days: 1 });
688
+ if (afterDate !== undefined && Temporal.PlainDate.compare(date, afterDate) <= 0) {
689
+ date = afterDate.add({ days: 1 });
690
+ }
691
+ for (; Temporal.PlainDate.compare(date, lastDate) <= 0; date = date.add({ days: 1 })) {
692
+ if (!matches(date))
693
+ continue;
694
+ const target = localInstant(date.toPlainDateTime(time), timeZone);
695
+ if (target === undefined)
696
+ continue;
697
+ if (target > MAX_FOUR_DIGIT_YEAR_MS)
698
+ return undefined;
699
+ if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > now)
700
+ return new Date(target).toISOString();
701
+ }
702
+ return undefined;
703
+ }
704
+ /** Find the latest actual occurrence at or before a decision without leaving the committed floor. */
705
+ function latestDueOccurrence(timeZone, matches, time, acceptedAt, savedTarget) {
706
+ // Offsets are less than 24 hours; a date-line rollback can make the current local date earlier than a due date.
707
+ let date = localDate(acceptedAt, 'UTC').add({ days: 1 });
708
+ for (;; date = date.subtract({ days: 1 })) {
709
+ if (!matches(date))
710
+ continue;
711
+ const candidate = localInstant(date.toPlainDateTime(time), timeZone);
712
+ if (candidate === undefined || candidate > acceptedAt)
713
+ continue;
714
+ // A committed target remains due even if current tzdata places the rule before it.
715
+ return Math.max(candidate, savedTarget);
716
+ }
717
+ }
718
+ /** Resolve a wall-clock rule only when its decision is at a representable four-digit-year instant. */
719
+ function acceptedDecision(selector, acceptedAt) {
720
+ if (!Number.isSafeInteger(acceptedAt)
721
+ || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) {
722
+ throw new ScheduleLogError(`${selector} acceptedAt must be a representable four-digit-year instant`);
723
+ }
724
+ return acceptedAt;
725
+ }
726
+ /** Find the first actual daily occurrence after now and, when supplied, after a delivered date. */
727
+ function nextDailyTarget(time, timeZone, now, afterDate) {
728
+ return nextMatchingTarget(time, timeZone, now, EVERY_DATE, afterDate);
729
+ }
730
+ /**
731
+ * Resolve a daily decision near the decision's local date, not across its missed history.
732
+ * @param record - Daily rule with a committed earliest unaccepted UTC target.
733
+ * @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.
734
+ * @returns Latest actual due occurrence and the next future occurrence on a later local date.
735
+ */
736
+ export function resolveDailyOccurrence(record, acceptedAt) {
737
+ const decision = acceptedDecision('daily', acceptedAt);
738
+ const savedTarget = Date.parse(record.scheduledAt);
739
+ if (decision < savedTarget)
740
+ throw new ScheduleLogError('daily dispatch cannot precede the active scheduledAt');
741
+ const time = dailyTime(record.time);
742
+ const occurrence = latestDueOccurrence(record.timeZone, EVERY_DATE, time, decision, savedTarget);
743
+ const occurrenceAt = new Date(occurrence).toISOString();
744
+ const nextScheduledAt = nextDailyTarget(time, record.timeZone, decision, localDate(occurrence, record.timeZone));
745
+ return Object.freeze(nextScheduledAt === undefined ? { occurrenceAt } : { occurrenceAt, nextScheduledAt });
746
+ }
747
+ /**
748
+ * Find the first actual weekly occurrence after now and, when supplied, after a delivered date.
749
+ * @param time - Normalized local clock time of the rule.
750
+ * @param timeZone - Explicit zone interpreting that clock time.
751
+ * @param weekdays - Normalized explicit ISO weekdays of the rule.
752
+ * @param now - Explicit decision time in epoch milliseconds.
753
+ * @param afterDate - Local date of the delivered occurrence, excluded from the search.
754
+ * @returns The first strictly future target, or exhaustion as undefined.
755
+ */
756
+ function nextWeeklyTarget(time, timeZone, weekdays, now, afterDate) {
757
+ return nextMatchingTarget(time, timeZone, now, weekdaySet(weekdays), afterDate);
758
+ }
759
+ /**
760
+ * Resolve a weekly decision near the decision's local date, not across its missed history.
761
+ * @param record - Weekly rule with a committed earliest unaccepted UTC target.
762
+ * @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.
763
+ * @returns Latest actual due occurrence and the next future occurrence on a selected weekday.
764
+ */
765
+ export function resolveWeeklyOccurrence(record, acceptedAt) {
766
+ const decision = acceptedDecision('weekly', acceptedAt);
767
+ const savedTarget = Date.parse(record.scheduledAt);
768
+ if (decision < savedTarget)
769
+ throw new ScheduleLogError('weekly dispatch cannot precede the active scheduledAt');
770
+ const time = weeklyTime(record.time);
771
+ const weekdays = normalizeWeekdays(record.weekdays);
772
+ const occurrence = latestDueOccurrence(record.timeZone, weekdaySet(weekdays), time, decision, savedTarget);
773
+ const occurrenceAt = new Date(occurrence).toISOString();
774
+ const nextScheduledAt = nextWeeklyTarget(time, record.timeZone, weekdays, decision, localDate(occurrence, record.timeZone));
775
+ return Object.freeze(nextScheduledAt === undefined ? { occurrenceAt } : { occurrenceAt, nextScheduledAt });
776
+ }
777
+ /**
778
+ * Build the local-date predicate of one parsed cron rule under Vixie day-of-month/day-of-week semantics.
779
+ *
780
+ * The star flag selects the branch only; it never excuses a field's matched values.
781
+ * When either field text starts with `*`, a local date must satisfy BOTH fields, so a
782
+ * stepped star such as `*` followed by `/2` still restricts the dates it matches. A bare
783
+ * `*` matches every value, which makes that field's match always true and degrades the
784
+ * AND to the other field, exactly like Vixie's `DOM_STAR`/`DOW_STAR` test. Only when
785
+ * neither field text starts with `*` does either field matching suffice.
786
+ * @param parsed - Parsed cron rule with canonical field text and matched values.
787
+ * @returns Whether one local date matches the rule.
788
+ */
789
+ function cronDateMatch(parsed) {
790
+ const months = new Set(parsed.months);
791
+ const daysOfMonth = new Set(parsed.daysOfMonth);
792
+ const daysOfWeek = new Set(parsed.daysOfWeek);
793
+ const dayOfMonthRestricted = !parsed.dayOfMonthStar;
794
+ const dayOfWeekRestricted = !parsed.dayOfWeekStar;
795
+ return (date) => {
796
+ /* v8 ignore next -- both callers pre-filter by month before matching a date. */
797
+ if (!months.has(date.month))
798
+ return false;
799
+ const dayOfMonth = daysOfMonth.has(date.day);
800
+ const dayOfWeek = daysOfWeek.has(date.dayOfWeek % 7);
801
+ if (dayOfMonthRestricted && dayOfWeekRestricted)
802
+ return dayOfMonth || dayOfWeek;
803
+ return dayOfMonth && dayOfWeek;
804
+ };
805
+ }
806
+ /** Enumerate one parsed cron rule's local times of day in ascending wall-clock order. */
807
+ function cronTimes(parsed) {
808
+ const times = [];
809
+ for (const hour of parsed.hours) {
810
+ for (const minute of parsed.minutes)
811
+ times.push(Temporal.PlainTime.from({ hour, minute }));
812
+ }
813
+ return times;
814
+ }
815
+ /**
816
+ * Find the first strictly future cron occurrence without leaving the four-digit UTC year range.
817
+ *
818
+ * The walk stops at {@link CRON_SEARCH_HORIZON_YEARS} past the floor date when that
819
+ * horizon precedes the four-digit ceiling: a rule with no match inside the horizon
820
+ * never matches, so its search resolves to exhaustion instead of scanning to the ceiling.
821
+ * @param parsed - Parsed cron rule.
822
+ * @param timeZone - Explicit zone interpreting its local date and time.
823
+ * @param floor - Instant every returned target must exceed.
824
+ * @returns The first matching target, or exhaustion as undefined.
825
+ */
826
+ function nextCronTarget(parsed, timeZone, floor) {
827
+ const times = cronTimes(parsed);
828
+ const matches = cronDateMatch(parsed);
829
+ const months = new Set(parsed.months);
830
+ const firstDate = localDate(floor, timeZone);
831
+ let date = firstDate;
832
+ // The floor's own local wall clock, so the first date converts only candidates
833
+ // that can exceed it instead of its whole day of times.
834
+ const floorTime = Temporal.Instant.fromEpochMilliseconds(floor).toZonedDateTimeISO(timeZone).toPlainTime();
835
+ const ceiling = localDate(MAX_FOUR_DIGIT_YEAR_MS, 'UTC').add({ days: 1 });
836
+ const horizon = date.add({ years: CRON_SEARCH_HORIZON_YEARS });
837
+ const lastDate = Temporal.PlainDate.compare(horizon, ceiling) < 0 ? horizon : ceiling;
838
+ while (Temporal.PlainDate.compare(date, lastDate) <= 0) {
839
+ if (!months.has(date.month)) {
840
+ date = date.add({ months: 1 }).with({ day: 1 });
841
+ continue;
842
+ }
843
+ if (matches(date)) {
844
+ for (const time of times) {
845
+ if (date.equals(firstDate) && Temporal.PlainTime.compare(time, floorTime) < 0)
846
+ continue;
847
+ const target = localInstant(date.toPlainDateTime(time), timeZone);
848
+ if (target === undefined)
849
+ continue;
850
+ if (target > MAX_FOUR_DIGIT_YEAR_MS)
851
+ return undefined;
852
+ if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > floor)
853
+ return new Date(target).toISOString();
854
+ }
855
+ }
856
+ date = date.add({ days: 1 });
857
+ }
858
+ return undefined;
859
+ }
860
+ /**
861
+ * Find the latest matching cron occurrence at or before a decision without leaving the committed floor.
862
+ *
863
+ * The walk stops at {@link CRON_SEARCH_HORIZON_YEARS} before the decision date when that
864
+ * horizon follows the four-digit floor, which is the local date holding that floor instant
865
+ * in the rule's own zone: a rule with no match inside the horizon keeps the committed target
866
+ * instead of scanning back to the floor, and a candidate before the floor instant is skipped.
867
+ * @param parsed - Parsed cron rule.
868
+ * @param timeZone - Explicit zone interpreting its local date and time.
869
+ * @param decision - Wall-clock decision time in epoch milliseconds.
870
+ * @param savedTarget - Committed target that stays due even when current zone data resolves past it.
871
+ * @returns The latest due occurrence, or the committed target when no local occurrence is representable.
872
+ */
873
+ function latestCronOccurrence(parsed, timeZone, decision, savedTarget) {
874
+ const times = cronTimes(parsed);
875
+ const matches = cronDateMatch(parsed);
876
+ const months = new Set(parsed.months);
877
+ // Offsets are less than 24 hours; a date-line rollback can make a due local date
878
+ // later than the UTC decision date, which is why the walk starts one day after it.
879
+ let date = localDate(decision, 'UTC').add({ days: 1 });
880
+ // The floor is an instant: the earliest local date the walk may reach is the one
881
+ // holding it in the rule's own zone, and candidates below it are skipped, so a
882
+ // negative offset can still resolve a due local minute dated before the UTC floor.
883
+ const floorDate = localDate(MIN_FOUR_DIGIT_YEAR_MS, timeZone);
884
+ const horizon = date.subtract({ years: CRON_SEARCH_HORIZON_YEARS });
885
+ const firstDate = Temporal.PlainDate.compare(horizon, floorDate) > 0 ? horizon : floorDate;
886
+ while (Temporal.PlainDate.compare(date, firstDate) >= 0) {
887
+ if (!months.has(date.month)) {
888
+ date = date.with({ day: 1 }).subtract({ days: 1 });
889
+ continue;
890
+ }
891
+ if (matches(date)) {
892
+ // One conversion of the date's earliest wall clock decides whether the whole
893
+ // date lies beyond the decision: every later time on it is later too, so the
894
+ // walk steps back instead of converting the rest of a day that cannot match.
895
+ // An earliest time that a DST gap removes leaves the bound to the first time
896
+ // that does exist, and a date with no existing time keeps the full walk.
897
+ const earliest = times.reduce((found, time) => found ?? localInstant(date.toPlainDateTime(time), timeZone), undefined);
898
+ if (earliest !== undefined && earliest > decision) {
899
+ date = date.subtract({ days: 1 });
900
+ continue;
901
+ }
902
+ for (const time of [...times].reverse()) {
903
+ const candidate = localInstant(date.toPlainDateTime(time), timeZone);
904
+ if (candidate === undefined || candidate > decision || candidate < MIN_FOUR_DIGIT_YEAR_MS)
905
+ continue;
906
+ // A committed target remains due even if current tzdata places the rule before it.
907
+ return Math.max(candidate, savedTarget);
908
+ }
909
+ }
910
+ date = date.subtract({ days: 1 });
911
+ }
912
+ return savedTarget;
913
+ }
914
+ /**
915
+ * Resolve a cron decision near the decision's local date, not across its missed history.
916
+ * @param record - Cron rule with a committed earliest unaccepted UTC target.
917
+ * @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.
918
+ * @returns Latest actual due occurrence and the first future occurrence.
919
+ */
920
+ export function resolveCronOccurrence(record, acceptedAt) {
921
+ const decision = acceptedDecision('cron', acceptedAt);
922
+ const savedTarget = Date.parse(record.scheduledAt);
923
+ if (decision < savedTarget)
924
+ throw new ScheduleLogError('cron dispatch cannot precede the active scheduledAt');
925
+ const parsed = parseCronExpression(record.expression);
926
+ const occurrence = latestCronOccurrence(parsed, record.timeZone, decision, savedTarget);
927
+ const occurrenceAt = new Date(occurrence).toISOString();
928
+ const nextScheduledAt = nextCronTarget(parsed, record.timeZone, decision);
929
+ return Object.freeze(nextScheduledAt === undefined ? { occurrenceAt } : { occurrenceAt, nextScheduledAt });
930
+ }
931
+ /**
932
+ * Identify recurring Host records explicitly, excluding both one-shot variants.
933
+ * @param record - Current Host schedule record.
934
+ * @returns Whether the record uses a recurring rule.
935
+ */
936
+ export function isRecurringScheduleRecord(record) {
937
+ return record.kind === 'every' || record.kind === 'daily' || record.kind === 'weekly' || record.kind === 'cron';
938
+ }
939
+ /**
940
+ * Resolve one due recurring Host record with its rule-specific calendar or interval arithmetic.
941
+ * @param record - Due recurring rule.
942
+ * @param acceptedAt - Explicit decision time in epoch milliseconds.
943
+ * @returns Latest due occurrence and optional future target.
944
+ */
945
+ export function resolveRecurringOccurrence(record, acceptedAt) {
946
+ switch (record.kind) {
947
+ case 'every': return resolveEveryOccurrence(record, acceptedAt);
948
+ case 'daily': return resolveDailyOccurrence(record, acceptedAt);
949
+ case 'weekly': return resolveWeeklyOccurrence(record, acceptedAt);
950
+ case 'cron': return resolveCronOccurrence(record, acceptedAt);
951
+ }
952
+ }
953
+ /** Apply one decoded dispatch to its exact active record. */
954
+ function dispatchedRecord(record, change) {
955
+ const hasAcceptedAt = 'acceptedAt' in change;
956
+ if (record.kind !== 'every') {
957
+ if (hasAcceptedAt)
958
+ throw new ScheduleLogError('one-shot dispatch must not contain acceptedAt');
959
+ return undefined;
960
+ }
961
+ if (!hasAcceptedAt)
962
+ throw new ScheduleLogError('every dispatch must contain acceptedAt');
963
+ const occurrence = resolveEveryOccurrence(record, Date.parse(change.acceptedAt));
964
+ return occurrence.nextScheduledAt === undefined
965
+ ? undefined
966
+ : Object.freeze({ ...record, scheduledAt: occurrence.nextScheduledAt });
967
+ }
968
+ /**
969
+ * Apply already-decoded Schedule changes to one complete fold value.
970
+ *
971
+ * The transition authority for full-log replay. One mutable Map/Set pair spans
972
+ * the whole batch; the returned arrays are materialized and frozen once.
973
+ * @param folded - complete active records and used-id history before the changes.
974
+ * @param changes - strictly decoded durable mutations in log order.
975
+ * @returns the complete fold value after every mutation.
976
+ */
977
+ export function applyScheduleChanges(folded, changes) {
978
+ const active = new Map(folded.active.map(record => [record.id, record]));
979
+ const seen = new Set(folded.seenIds);
980
+ for (const change of changes) {
981
+ switch (change.operation) {
982
+ case 'create':
983
+ if (seen.has(change.schedule.id)) {
984
+ throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`);
985
+ }
986
+ seen.add(change.schedule.id);
987
+ active.set(change.schedule.id, change.schedule);
988
+ break;
989
+ case 'delete':
990
+ if (!active.delete(change.id)) {
991
+ throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`);
992
+ }
993
+ break;
994
+ case 'dispatch': {
995
+ const record = active.get(change.id);
996
+ if (record === undefined) {
997
+ throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`);
998
+ }
999
+ const next = dispatchedRecord(record, change);
1000
+ if (next === undefined)
1001
+ active.delete(change.id);
1002
+ else
1003
+ active.set(change.id, next);
1004
+ break;
1005
+ }
1006
+ /* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
1007
+ default: {
1008
+ const unreachable = change;
1009
+ throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`);
1010
+ }
1011
+ }
1012
+ }
1013
+ return Object.freeze({
1014
+ active: Object.freeze([...active.values()]),
1015
+ seenIds: Object.freeze([...seen]),
1016
+ });
1017
+ }
1018
+ /**
1019
+ * Fold the package-owned stream after the durable fork seed boundary.
1020
+ * @param events - Complete ordered session log or candidate-extended log.
1021
+ * @param inheritedEventCount - Inherited prefix length excluded from child ownership.
1022
+ * @returns Active records and all previously used ids.
1023
+ */
1024
+ export function foldScheduleEvents(events, inheritedEventCount = SessionLogOffset(0)) {
1025
+ if (!Number.isSafeInteger(inheritedEventCount)
1026
+ || inheritedEventCount < 0
1027
+ || inheritedEventCount > events.length) {
1028
+ throw new ScheduleLogError('schedule inheritedEventCount must be within the supplied event log');
1029
+ }
1030
+ const initial = Object.freeze({
1031
+ active: Object.freeze([]),
1032
+ seenIds: Object.freeze([]),
1033
+ });
1034
+ const changes = function* () {
1035
+ for (const event of events.slice(inheritedEventCount)) {
1036
+ if (event.type === 'schedule/change')
1037
+ yield decodeScheduleChange(event.data);
1038
+ }
1039
+ };
1040
+ return applyScheduleChanges(initial, changes());
1041
+ }
1042
+ /**
1043
+ * Allocate the next readable id without reusing any prior session-local id.
1044
+ * @param folded - Fold containing every previously created id.
1045
+ * @returns A fresh `schedule-N` identity.
1046
+ */
1047
+ export function allocateScheduleId(folded) {
1048
+ const seen = new Set(folded.seenIds);
1049
+ let sequence = seen.size + 1;
1050
+ let candidate = ScheduleId(`schedule-${sequence}`);
1051
+ while (seen.has(candidate)) {
1052
+ sequence += 1;
1053
+ candidate = ScheduleId(`schedule-${sequence}`);
1054
+ }
1055
+ return candidate;
1056
+ }
1057
+ /**
1058
+ * Validate a model after rule and compute its durable target.
1059
+ * @param id - Already allocated task id.
1060
+ * @param prompt - Reminder content supplied at creation.
1061
+ * @param afterSeconds - Requested positive delay.
1062
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1063
+ * @param title - Required task name supplied at creation.
1064
+ * @returns Frozen durable after record.
1065
+ */
1066
+ export function createAfterScheduleRecord(id, prompt, afterSeconds, now, title) {
1067
+ const normalizedPrompt = prompt.trim();
1068
+ if (normalizedPrompt.length === 0) {
1069
+ throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
1070
+ }
1071
+ if (!Number.isSafeInteger(afterSeconds) || afterSeconds <= 0) {
1072
+ throw new ScheduleInputError('invalid_rule', 'after_seconds must be a positive safe integer.');
1073
+ }
1074
+ const delay = afterSeconds * 1_000;
1075
+ const target = now + delay;
1076
+ return Object.freeze({
1077
+ id,
1078
+ kind: 'after',
1079
+ title: scheduleTitle(title),
1080
+ prompt: normalizedPrompt,
1081
+ afterSeconds,
1082
+ scheduledAt: futureInstant(target, now),
1083
+ });
1084
+ }
1085
+ /**
1086
+ * Validate an absolute selector and compute its sole durable UTC target.
1087
+ * @param id - Already allocated task id.
1088
+ * @param prompt - Reminder content supplied at creation.
1089
+ * @param at - Explicit-offset instant or structured local calendar value.
1090
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1091
+ * @param title - Required task name supplied at creation.
1092
+ * @returns Frozen durable absolute one-shot record.
1093
+ */
1094
+ export function createAtScheduleRecord(id, prompt, at, now, title) {
1095
+ const normalizedPrompt = prompt.trim();
1096
+ if (normalizedPrompt.length === 0) {
1097
+ throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
1098
+ }
1099
+ return Object.freeze({
1100
+ id,
1101
+ kind: 'at',
1102
+ title: scheduleTitle(title),
1103
+ prompt: normalizedPrompt,
1104
+ scheduledAt: futureInstant(parseAtInput(at), now),
1105
+ });
1106
+ }
1107
+ /**
1108
+ * Parse an absolute selector without requiring it to be future.
1109
+ * @param at - Explicit-offset instant or strict local calendar input.
1110
+ * @returns Resolved epoch milliseconds; malformed input throws ScheduleInputError.
1111
+ */
1112
+ export function parseAtInput(at) {
1113
+ let target;
1114
+ if (typeof at === 'string') {
1115
+ target = parseOffsetInstant(at);
1116
+ }
1117
+ else if (isRecord(at)) {
1118
+ if (!hasExactKeys(at, ['date', 'time', 'time_zone'])) {
1119
+ throw new ScheduleInputError('invalid_rule', 'Local at must contain exactly date, time, and time_zone.');
1120
+ }
1121
+ if (typeof at['date'] !== 'string' || typeof at['time'] !== 'string') {
1122
+ throw new ScheduleInputError('invalid_rule', 'Local at date and time must be strings.');
1123
+ }
1124
+ const rawTimeZone = at['time_zone'];
1125
+ if (typeof rawTimeZone !== 'string') {
1126
+ throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.');
1127
+ }
1128
+ const local = {
1129
+ date: at['date'],
1130
+ time: at['time'],
1131
+ time_zone: rawTimeZone,
1132
+ };
1133
+ target = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone));
1134
+ }
1135
+ else {
1136
+ throw new ScheduleInputError('invalid_rule', 'at must be an explicit-offset string or local calendar object.');
1137
+ }
1138
+ return target;
1139
+ }
1140
+ /**
1141
+ * Validate a fixed-rate selector and compute the first target of a new interval anchor.
1142
+ * @param id - Already allocated task id.
1143
+ * @param prompt - Reminder content supplied at creation.
1144
+ * @param everySeconds - Requested fixed safe-integer interval.
1145
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1146
+ * @param title - Required task name supplied at creation.
1147
+ * @returns Frozen durable fixed-rate record.
1148
+ */
1149
+ export function createEveryScheduleRecord(id, prompt, everySeconds, now, title) {
1150
+ const normalizedPrompt = prompt.trim();
1151
+ if (normalizedPrompt.length === 0) {
1152
+ throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
1153
+ }
1154
+ if (!Number.isSafeInteger(everySeconds)) {
1155
+ throw new ScheduleInputError('invalid_rule', 'every_seconds must be a safe integer.');
1156
+ }
1157
+ if (everySeconds < MIN_EVERY_INTERVAL_SECONDS) {
1158
+ throw new ScheduleInputError('frequency_too_high', `every_seconds must be at least ${MIN_EVERY_INTERVAL_SECONDS}.`);
1159
+ }
1160
+ const interval = everySeconds * 1_000;
1161
+ const target = now + interval;
1162
+ return Object.freeze({
1163
+ id,
1164
+ kind: 'every',
1165
+ title: scheduleTitle(title),
1166
+ prompt: normalizedPrompt,
1167
+ everySeconds,
1168
+ scheduledAt: futureInstant(target, now),
1169
+ });
1170
+ }
1171
+ /**
1172
+ * Create a daily wall-clock rule with a strictly future committed UTC target.
1173
+ * @param id - Already allocated task identity.
1174
+ * @param prompt - Reminder content supplied at creation.
1175
+ * @param daily - Strict local time and explicit IANA zone.
1176
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1177
+ * @param title - Required task name supplied at creation.
1178
+ * @returns Frozen daily record; absent future dates throw time_out_of_range.
1179
+ */
1180
+ export function createDailyScheduleRecord(id, prompt, daily, now, title) {
1181
+ const normalizedPrompt = prompt.trim();
1182
+ if (normalizedPrompt.length === 0) {
1183
+ throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
1184
+ }
1185
+ const { time, timeZone } = parseDailyInput(daily);
1186
+ if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {
1187
+ throw new ScheduleInputError('time_out_of_range', 'Daily creation time must be a representable four-digit-year UTC instant.');
1188
+ }
1189
+ const scheduledAt = nextDailyTarget(dailyTime(time), timeZone, now);
1190
+ if (scheduledAt === undefined) {
1191
+ throw new ScheduleInputError('time_out_of_range', 'No future daily occurrence is representable as a four-digit-year UTC instant.');
1192
+ }
1193
+ return Object.freeze({
1194
+ id, kind: 'daily', title: scheduleTitle(title), prompt: normalizedPrompt, time, timeZone, scheduledAt,
1195
+ });
1196
+ }
1197
+ /**
1198
+ * Normalize a daily selector without calculating a new committed target.
1199
+ * @param daily - Strict local time and explicit IANA zone.
1200
+ * @returns Normalized time and canonical zone; malformed input throws ScheduleInputError.
1201
+ */
1202
+ export function parseDailyInput(daily) {
1203
+ if (!isRecord(daily) || !hasExactKeys(daily, ['time', 'time_zone']) || typeof daily['time'] !== 'string') {
1204
+ throw new ScheduleInputError('invalid_rule', 'daily must contain exactly time and time_zone, with time HH:mm:ss and optional 1-3 fractional digits.');
1205
+ }
1206
+ if (typeof daily['time_zone'] !== 'string') {
1207
+ throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.');
1208
+ }
1209
+ return {
1210
+ time: dailyTime(daily['time']).toString({ fractionalSecondDigits: 3 }),
1211
+ timeZone: canonicalizeTimeZone(daily['time_zone']),
1212
+ };
1213
+ }
1214
+ /**
1215
+ * Create a weekly wall-clock rule with a strictly future committed UTC target.
1216
+ * @param id - Already allocated task identity.
1217
+ * @param prompt - Reminder content supplied at creation.
1218
+ * @param weekly - Strict local time, explicit IANA zone, and explicit ISO weekday set.
1219
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1220
+ * @param title - Required task name supplied at creation.
1221
+ * @returns Frozen weekly record; absent future weekdays throw time_out_of_range.
1222
+ */
1223
+ export function createWeeklyScheduleRecord(id, prompt, weekly, now, title) {
1224
+ const normalizedPrompt = prompt.trim();
1225
+ if (normalizedPrompt.length === 0) {
1226
+ throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
1227
+ }
1228
+ const { time, timeZone, weekdays } = parseWeeklyInput(weekly);
1229
+ if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {
1230
+ throw new ScheduleInputError('time_out_of_range', 'Weekly creation time must be a representable four-digit-year UTC instant.');
1231
+ }
1232
+ const scheduledAt = nextWeeklyTarget(weeklyTime(time), timeZone, weekdays, now);
1233
+ if (scheduledAt === undefined) {
1234
+ throw new ScheduleInputError('time_out_of_range', 'No future weekly occurrence is representable as a four-digit-year UTC instant.');
1235
+ }
1236
+ return Object.freeze({
1237
+ id, kind: 'weekly', title: scheduleTitle(title), prompt: normalizedPrompt, time, timeZone,
1238
+ weekdays, scheduledAt,
1239
+ });
1240
+ }
1241
+ /**
1242
+ * Create a cron wall-clock rule with a strictly future committed UTC target.
1243
+ * @param id - Already allocated task identity.
1244
+ * @param prompt - Reminder content supplied at creation.
1245
+ * @param cron - Strict five-field expression and explicit IANA zone.
1246
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1247
+ * @param title - Required task name supplied at creation.
1248
+ * @returns Frozen cron record; absent future occurrences throw time_out_of_range.
1249
+ */
1250
+ export function createCronScheduleRecord(id, prompt, cron, now, title) {
1251
+ const normalizedPrompt = prompt.trim();
1252
+ if (normalizedPrompt.length === 0) {
1253
+ throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
1254
+ }
1255
+ const { expression, timeZone } = parseCronInput(cron);
1256
+ if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {
1257
+ throw new ScheduleInputError('time_out_of_range', 'Cron creation time must be a representable four-digit-year UTC instant.');
1258
+ }
1259
+ const scheduledAt = nextCronTarget(parseCronExpression(expression), timeZone, now);
1260
+ if (scheduledAt === undefined) {
1261
+ throw new ScheduleInputError('time_out_of_range', 'No future cron occurrence is representable as a four-digit-year UTC instant.');
1262
+ }
1263
+ return Object.freeze({
1264
+ id, kind: 'cron', title: scheduleTitle(title), prompt: normalizedPrompt, expression, timeZone, scheduledAt,
1265
+ });
1266
+ }
1267
+ /**
1268
+ * Normalize a weekly selector without calculating a new committed target.
1269
+ * @param weekly - Strict local time, explicit IANA zone, and explicit ISO weekday set.
1270
+ * @returns Normalized time, canonical zone, and unique ascending weekdays; malformed input throws ScheduleInputError.
1271
+ */
1272
+ export function parseWeeklyInput(weekly) {
1273
+ if (!isRecord(weekly)
1274
+ || !hasExactKeys(weekly, ['time', 'time_zone', 'weekdays'])
1275
+ || typeof weekly['time'] !== 'string') {
1276
+ throw new ScheduleInputError('invalid_rule', 'weekly must contain exactly time, time_zone, and weekdays, with time HH:mm:ss and optional 1-3 fractional digits.');
1277
+ }
1278
+ if (typeof weekly['time_zone'] !== 'string') {
1279
+ throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.');
1280
+ }
1281
+ return {
1282
+ time: weeklyTime(weekly['time']).toString({ fractionalSecondDigits: 3 }),
1283
+ timeZone: canonicalizeTimeZone(weekly['time_zone']),
1284
+ weekdays: normalizeWeekdays(weekly['weekdays']),
1285
+ };
1286
+ }
1287
+ const CRON_FIELDS = [
1288
+ { name: 'minute', min: 0, max: 59, canonicalMax: 59 },
1289
+ { name: 'hour', min: 0, max: 23, canonicalMax: 23 },
1290
+ { name: 'day-of-month', min: 1, max: 31, canonicalMax: 31 },
1291
+ { name: 'month', min: 1, max: 12, canonicalMax: 12 },
1292
+ { name: 'day-of-week', min: 0, max: 7, canonicalMax: 6 },
1293
+ ];
1294
+ /** Build the stable diagnostic for one malformed cron field element. */
1295
+ function invalidCronField(spec, element) {
1296
+ return new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field element ${JSON.stringify(element)} must be *, a value, a-b, */n, a-b/n, `
1297
+ + 'or a comma-separated list of those.');
1298
+ }
1299
+ /** Read one in-range cron field value. */
1300
+ function cronFieldValue(text, spec) {
1301
+ const value = Number(text);
1302
+ if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) {
1303
+ throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field value ${text} is outside ${spec.min}-${spec.max}.`);
1304
+ }
1305
+ return value;
1306
+ }
1307
+ /** Read one positive cron field step. */
1308
+ function cronFieldStep(text, spec) {
1309
+ const step = Number(text);
1310
+ if (!Number.isSafeInteger(step) || step < 1) {
1311
+ throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field step must be a positive integer.`);
1312
+ }
1313
+ return step;
1314
+ }
1315
+ /** Expand one comma-separated cron field into its matched value set. */
1316
+ function cronFieldValues(raw, spec) {
1317
+ const matched = new Set();
1318
+ for (const element of raw.split(',')) {
1319
+ if (element.length === 0) {
1320
+ throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field must not contain an empty list element.`);
1321
+ }
1322
+ if (element === '*') {
1323
+ for (let value = spec.min; value <= spec.max; value += 1)
1324
+ matched.add(value);
1325
+ continue;
1326
+ }
1327
+ if (element.startsWith('*')) {
1328
+ const stepped = /^\*\/(?<step>\d+)$/.exec(element);
1329
+ const groups = stepped?.groups;
1330
+ if (groups === undefined)
1331
+ throw invalidCronField(spec, element);
1332
+ const stepText = groups['step'];
1333
+ /* v8 ignore next -- a successful fixed regex always provides the step group. */
1334
+ if (stepText === undefined)
1335
+ throw invalidCronField(spec, element);
1336
+ const step = cronFieldStep(stepText, spec);
1337
+ for (let value = spec.min; value <= spec.max; value += step)
1338
+ matched.add(value);
1339
+ continue;
1340
+ }
1341
+ const parsed = /^(?<start>\d+)(?:-(?<end>\d+))?(?:\/(?<step>\d+))?$/.exec(element);
1342
+ const groups = parsed?.groups;
1343
+ if (groups === undefined)
1344
+ throw invalidCronField(spec, element);
1345
+ const startText = groups['start'];
1346
+ /* v8 ignore next -- a successful fixed regex always provides the start group. */
1347
+ if (startText === undefined)
1348
+ throw invalidCronField(spec, element);
1349
+ const start = cronFieldValue(startText, spec);
1350
+ const end = groups['end'];
1351
+ const stepText = groups['step'];
1352
+ if (end === undefined) {
1353
+ if (stepText !== undefined)
1354
+ throw invalidCronField(spec, element);
1355
+ matched.add(start);
1356
+ continue;
1357
+ }
1358
+ const last = cronFieldValue(end, spec);
1359
+ if (start > last) {
1360
+ throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field range ${start}-${last} is inverted.`);
1361
+ }
1362
+ const step = stepText === undefined ? 1 : cronFieldStep(stepText, spec);
1363
+ for (let value = start; value <= last; value += step)
1364
+ matched.add(value);
1365
+ }
1366
+ const values = new Set();
1367
+ for (const value of matched) {
1368
+ values.add(spec.canonicalMax !== spec.max && value === spec.max ? spec.min : value);
1369
+ }
1370
+ return [...values].sort((left, right) => left - right);
1371
+ }
1372
+ /**
1373
+ * Read one value the encoder already proved present.
1374
+ * @param values - matched value set the parser always fills.
1375
+ * @param index - index the caller derived from that set.
1376
+ * @returns the value at that index.
1377
+ */
1378
+ function cronFieldValueAt(values, index) {
1379
+ const value = values[index];
1380
+ /* v8 ignore next -- a parsed cron field always matches at least one value. */
1381
+ if (value === undefined)
1382
+ throw new ScheduleLogError('cron field encoding requires a non-empty value set');
1383
+ return value;
1384
+ }
1385
+ /** Encode one matched value set as the shortest equivalent comma-separated cron field.
1386
+ * @param values - Matched values in ascending order.
1387
+ * @returns Field text that re-parses to exactly `values`, never spelled with a leading `*`.
1388
+ */
1389
+ function encodeCronField(values) {
1390
+ const first = cronFieldValueAt(values, 0);
1391
+ if (values.length === 1)
1392
+ return String(first);
1393
+ const last = cronFieldValueAt(values, values.length - 1);
1394
+ const step = cronFieldValueAt(values, 1) - first;
1395
+ let uniform = true;
1396
+ for (let index = 2; index < values.length; index += 1) {
1397
+ if (cronFieldValueAt(values, index) - cronFieldValueAt(values, index - 1) !== step) {
1398
+ uniform = false;
1399
+ break;
1400
+ }
1401
+ }
1402
+ if (uniform) {
1403
+ if (step === 1)
1404
+ return `${first}-${last}`;
1405
+ return `${first}-${last}/${step}`;
1406
+ }
1407
+ const parts = [];
1408
+ let runStart = first;
1409
+ for (let index = 1; index < values.length; index += 1) {
1410
+ const current = cronFieldValueAt(values, index);
1411
+ const previous = cronFieldValueAt(values, index - 1);
1412
+ if (current === previous + 1)
1413
+ continue;
1414
+ parts.push(runStart === previous ? String(runStart) : `${runStart}-${previous}`);
1415
+ runStart = current;
1416
+ }
1417
+ const final = cronFieldValueAt(values, values.length - 1);
1418
+ parts.push(runStart === final ? String(runStart) : `${runStart}-${final}`);
1419
+ return parts.join(',');
1420
+ }
1421
+ /**
1422
+ * One uniform walk from a field's minimum, folded exactly as a parsed field folds.
1423
+ * @param spec - Field range and canonical maximum.
1424
+ * @param step - Positive step of the walk.
1425
+ * @returns Ascending values `step` produces from the minimum.
1426
+ */
1427
+ function starWalk(spec, step) {
1428
+ const walked = new Set();
1429
+ for (let value = spec.min; value <= spec.max; value += step) {
1430
+ walked.add(spec.canonicalMax !== spec.max && value === spec.max ? spec.min : value);
1431
+ }
1432
+ return [...walked].sort((left, right) => left - right);
1433
+ }
1434
+ /**
1435
+ * Canonical spelling of a field whose text started with `*`.
1436
+ *
1437
+ * Only a `*`-prefixed spelling keeps the star flag, and only the bare star or a star-step
1438
+ * (a `*` followed by `/n`) admits a leading `*`, so the encoding is the bare star for every
1439
+ * value, otherwise the widest star-step walk plus any remaining values as a list.
1440
+ * Re-parsing therefore reproduces both the star flag and the matched set, which is what
1441
+ * keeps day-of-month/day-of-week AND/OR selection stable across a stored canonical
1442
+ * expression.
1443
+ * @param values - Matched values in ascending order.
1444
+ * @param spec - Field range and canonical maximum.
1445
+ * @returns Canonical field text that starts with `*`.
1446
+ */
1447
+ function encodeStarCronField(values, spec) {
1448
+ if (values.length === spec.canonicalMax - spec.min + 1)
1449
+ return '*';
1450
+ const present = new Set(values);
1451
+ let bestStep;
1452
+ let bestWalk = [];
1453
+ for (let step = 1; step <= spec.max - spec.min + 1; step += 1) {
1454
+ const walk = starWalk(spec, step);
1455
+ if (walk.length > bestWalk.length && walk.every(value => present.has(value))) {
1456
+ bestStep = step;
1457
+ bestWalk = walk;
1458
+ }
1459
+ }
1460
+ /* v8 ignore next -- the widest step yields the single minimum, which every `*`-led field matches. */
1461
+ if (bestStep === undefined)
1462
+ throw new ScheduleInputError('invalid_rule', `${spec.name} cannot keep a leading \`*\`.`);
1463
+ const walked = new Set(bestWalk);
1464
+ const remaining = values.filter(value => !walked.has(value));
1465
+ const walkText = `*/${bestStep}`;
1466
+ return remaining.length === 0
1467
+ ? walkText
1468
+ : `${walkText},${encodeCronField(remaining)}`;
1469
+ }
1470
+ /**
1471
+ * Parse one cron field into its matched values, canonical spelling, and star flag.
1472
+ *
1473
+ * The star flag tests the field text's first character, which is how Vixie sets
1474
+ * `DOM_STAR`/`DOW_STAR`: a stepped star (`*` followed by `/2`) is a star although
1475
+ * it matches half the range, while an explicit full range such as `1-31` is
1476
+ * restricted. Canonicalization preserves the flag: a `*`-led field keeps a `*`-led
1477
+ * spelling, and a field that did not start with `*` is never spelled as a star-step.
1478
+ */
1479
+ function parseCronField(raw, spec) {
1480
+ const values = cronFieldValues(raw, spec);
1481
+ const star = raw.startsWith('*');
1482
+ return Object.freeze({
1483
+ values: Object.freeze(values),
1484
+ canonical: star ? encodeStarCronField(values, spec) : encodeCronField(values),
1485
+ star,
1486
+ });
1487
+ }
1488
+ /** Parse and canonicalize one strict five-field cron expression. */
1489
+ function parseCronExpression(expression) {
1490
+ if (typeof expression !== 'string' || expression.length === 0 || expression.trim() !== expression) {
1491
+ throw new ScheduleInputError('invalid_rule', 'cron.expression must be a non-empty trimmed string.');
1492
+ }
1493
+ const fields = expression.split(/\s+/);
1494
+ if (fields.length !== 5) {
1495
+ throw new ScheduleInputError('invalid_rule', 'cron.expression must contain exactly five whitespace-separated fields: '
1496
+ + 'minute hour day-of-month month day-of-week.');
1497
+ }
1498
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields.map((field, index) => {
1499
+ const fieldSpec = CRON_FIELDS[index];
1500
+ /* v8 ignore next -- the five-field split bounds this index. */
1501
+ if (fieldSpec === undefined)
1502
+ throw new ScheduleLogError('cron field specification is missing');
1503
+ return parseCronField(field, fieldSpec);
1504
+ });
1505
+ return Object.freeze({
1506
+ expression: [minute, hour, dayOfMonth, month, dayOfWeek].map(field => field.canonical).join(' '),
1507
+ minutes: minute.values,
1508
+ hours: hour.values,
1509
+ daysOfMonth: dayOfMonth.values,
1510
+ months: month.values,
1511
+ daysOfWeek: dayOfWeek.values,
1512
+ dayOfMonthStar: dayOfMonth.star,
1513
+ dayOfWeekStar: dayOfWeek.star,
1514
+ });
1515
+ }
1516
+ /**
1517
+ * Canonicalize one strict five-field cron expression.
1518
+ * @param expression - Candidate `minute hour day-of-month month day-of-week` expression.
1519
+ * @returns The canonical expression; malformed or unsupported input throws ScheduleInputError.
1520
+ */
1521
+ export function canonicalizeCronExpression(expression) {
1522
+ return parseCronExpression(expression).expression;
1523
+ }
1524
+ /**
1525
+ * Normalize a cron selector without calculating a new committed target.
1526
+ * @param cron - Strict five-field expression and explicit IANA zone.
1527
+ * @returns The canonical expression and canonical zone; malformed input throws ScheduleInputError.
1528
+ */
1529
+ export function parseCronInput(cron) {
1530
+ if (!isRecord(cron) || !hasExactKeys(cron, ['expression', 'time_zone']) || typeof cron['expression'] !== 'string') {
1531
+ throw new ScheduleInputError('invalid_rule', 'cron must contain exactly expression and time_zone, with a five-field cron expression.');
1532
+ }
1533
+ if (typeof cron['time_zone'] !== 'string') {
1534
+ throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.');
1535
+ }
1536
+ return {
1537
+ expression: parseCronExpression(cron['expression']).expression,
1538
+ timeZone: canonicalizeTimeZone(cron['time_zone']),
1539
+ };
1540
+ }
1541
+ /**
1542
+ * Derive one execution-local management view.
1543
+ * @param record - Active durable record.
1544
+ * @param now - Wall-clock sample used for its timing state.
1545
+ * @returns Complete Host delivery view.
1546
+ */
1547
+ export function scheduleView(record, now) {
1548
+ return Object.freeze({
1549
+ ...record,
1550
+ state: now >= Date.parse(record.scheduledAt) ? 'overdue' : 'scheduled',
1551
+ deliveryMode: 'host',
1552
+ });
1553
+ }
1554
+ /** Fixed model-facing origin line shared by one-shot and recurring reminder delivery. */
1555
+ const SCHEDULED_MESSAGE_FRAMING = 'This is a scheduled message from the user';
1556
+ /**
1557
+ * Render the fixed model framing for a due reminder.
1558
+ * @param record - Due active record.
1559
+ * @returns Stable model-visible text with JSON-escaped dynamic fields.
1560
+ */
1561
+ export function renderReminderFraming(record) {
1562
+ return [
1563
+ '[SCHEDULE REMINDER]',
1564
+ SCHEDULED_MESSAGE_FRAMING,
1565
+ `schedule_id_json: ${JSON.stringify(record.id)}`,
1566
+ `occurrence_at: ${record.scheduledAt}`,
1567
+ `reminder_prompt_json: ${JSON.stringify(record.prompt)}`,
1568
+ ].join('\n');
1569
+ }
1570
+ /**
1571
+ * Render one recurring reminder batch in the supplied order.
1572
+ * @param reminders - Complete admitted batch with one latest occurrence per record.
1573
+ * @returns Stable model-visible text whose dynamic payload is canonical JSON.
1574
+ */
1575
+ export function renderRecurringReminderBatchFraming(reminders) {
1576
+ const payload = reminders.map(({ record, occurrenceAt }) => ({
1577
+ schedule_id: record.id,
1578
+ occurrence_at: occurrenceAt,
1579
+ reminder_prompt: record.prompt,
1580
+ }));
1581
+ return [
1582
+ '[SCHEDULE REMINDER BATCH]',
1583
+ SCHEDULED_MESSAGE_FRAMING,
1584
+ `reminders_json: ${JSON.stringify(payload)}`,
1585
+ ].join('\n');
1586
+ }
1587
+ //# sourceMappingURL=domain.js.map