返回源码地图

packages/schedule/schedule/src/domain.ts

main snapshot · da00f7f5358f · 正文引用章节 17 / 17;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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