@deepseek-ai/dsh-schedule 0.1.7-alpha.2 → 0.1.7-rc.2

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.
@@ -2,11 +2,24 @@
2
2
  * Strict Schedule decoding, replay, time validation, and framing.
3
3
  * @module @deepseek-ai/dsh-schedule
4
4
  */
5
+ import { Temporal } from '@js-temporal/polyfill';
5
6
  import { SessionLogOffset } from '@deepseek-ai/dsh-session';
6
7
  /** Durable Schedule protocol version implemented by this package. */
7
8
  export const SCHEDULE_CHANGE_VERSION = 1;
8
9
  /** Fixed v1 lower bound for a fixed-rate reminder. */
9
- export const MIN_EVERY_INTERVAL_SECONDS = 300;
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;
10
23
  const MIN_FOUR_DIGIT_YEAR_MS = Date.parse('0001-01-01T00:00:00.000Z');
11
24
  const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z');
12
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$/;
@@ -16,8 +29,8 @@ const OFFSET_INSTANT = new RegExp(String.raw `^(?<year>\d{4})-(?<month>\d{2})-(?
16
29
  + String.raw `(?<offsetHour>\d{2}):(?<offsetMinute>\d{2}))$`);
17
30
  const LOCAL_DATE = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;
18
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})?$/;
19
33
  const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
20
- const OFFSET_NAME = /^GMT(?:(?<sign>[+-])(?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\d{2}))?)?$/;
21
34
  /** Error from malformed or transition-invalid durable Schedule data. */
22
35
  export class ScheduleLogError extends Error {
23
36
  /** Stable machine-readable error code. */
@@ -61,9 +74,87 @@ function isRecord(value) {
61
74
  }
62
75
  /** Require exactly the named durable object keys. */
63
76
  function hasExactKeys(value, expected) {
64
- const keys = Object.keys(value).sort();
65
- const wanted = [...expected].sort();
66
- return keys.length === wanted.length && keys.every((key, index) => key === wanted[index]);
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']);
67
158
  }
68
159
  /** Validate one stable session-local id at the durable boundary. */
69
160
  function decodeId(value) {
@@ -207,88 +298,25 @@ function parseLocalAt(value) {
207
298
  calendarEpoch(parts);
208
299
  return parts;
209
300
  }
210
- /** Format one epoch into exact local fields and the zone offset that produced them. */
211
- function localProjection(formatter, epoch) {
212
- const values = Object.fromEntries(formatter.formatToParts(epoch).map(part => [part.type, part.value]));
213
- const zoneName = values['timeZoneName'];
214
- /* v8 ignore next -- a formatter configured with longOffset always emits this part. */
215
- const offsetMatch = typeof zoneName === 'string' ? OFFSET_NAME.exec(zoneName) : null;
216
- const offsetGroups = offsetMatch?.groups;
217
- /* v8 ignore next -- the formatter requested longOffset, whose part is defined by Intl. */
218
- if (offsetMatch === null || offsetGroups === undefined) {
219
- throw new ScheduleInputError('invalid_time_zone', 'time_zone did not expose a usable UTC offset.');
220
- }
221
- const direction = offsetGroups['sign'] === '-' ? -1 : 1;
222
- /* v8 ignore next -- some Intl builds spell UTC as bare GMT instead of GMT+00:00. */
223
- const offset = offsetGroups['sign'] === undefined
224
- ? 0
225
- : direction * (groupNumber(offsetGroups, 'hour') * 3600
226
- + groupNumber(offsetGroups, 'minute') * 60
227
- + Number(offsetGroups['second'] ?? '0')) * 1_000;
228
- return {
229
- year: Number(values['year']),
230
- month: Number(values['month']),
231
- day: Number(values['day']),
232
- hour: Number(values['hour']),
233
- minute: Number(values['minute']),
234
- second: Number(values['second']),
235
- millisecond: Number(values['fractionalSecond']),
236
- offset,
237
- };
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;
238
305
  }
239
- /** Resolve a local wall-clock value, choosing the first instant in an overlap and rejecting a gap. */
306
+ /** Resolve a local one-shot, rejecting nonexistent wall-clock times. */
240
307
  function resolveLocalInstant(parts, timeZone) {
241
- const localEpoch = calendarEpoch(parts);
242
- const formatter = new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
243
- timeZone,
244
- year: 'numeric',
245
- month: '2-digit',
246
- day: '2-digit',
247
- hour: '2-digit',
248
- minute: '2-digit',
249
- second: '2-digit',
250
- fractionalSecondDigits: 3,
251
- hourCycle: 'h23',
252
- timeZoneName: 'longOffset',
253
- });
254
- const offsets = new Set();
255
- for (const delta of [-172_800_000, -86_400_000, 0, 86_400_000, 172_800_000]) {
256
- const sample = Math.min(MAX_FOUR_DIGIT_YEAR_MS, Math.max(MIN_FOUR_DIGIT_YEAR_MS, localEpoch + delta));
257
- offsets.add(localProjection(formatter, sample).offset);
258
- }
259
- const candidates = [];
260
- let outOfRange = false;
261
- for (const offset of offsets) {
262
- const candidate = localEpoch - offset;
263
- if (candidate < MIN_FOUR_DIGIT_YEAR_MS || candidate > MAX_FOUR_DIGIT_YEAR_MS) {
264
- outOfRange = true;
265
- continue;
266
- }
267
- const projected = localProjection(formatter, candidate);
268
- if (projected.year === parts.year
269
- && projected.month === parts.month
270
- && projected.day === parts.day
271
- && projected.hour === parts.hour
272
- && projected.minute === parts.minute
273
- && projected.second === parts.second
274
- && projected.millisecond === parts.millisecond) {
275
- candidates.push(candidate);
276
- }
277
- }
278
- const first = candidates.sort((left, right) => left - right)[0];
279
- if (first === undefined) {
280
- if (outOfRange) {
281
- throw new ScheduleInputError('time_out_of_range', 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.');
282
- }
308
+ const target = localInstant(Temporal.PlainDateTime.from(parts, { overflow: 'reject' }), timeZone);
309
+ if (target === undefined) {
283
310
  throw new ScheduleInputError('invalid_rule', 'The local at time does not exist in the selected time zone.');
284
311
  }
285
- return first;
312
+ return target;
286
313
  }
287
314
  /** Decode the exact v1 after record shape. */
288
315
  function decodeAfterRecord(value) {
289
- if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) {
290
- throw new ScheduleLogError('after schedule must contain exactly id, kind, prompt, afterSeconds, and scheduledAt');
291
- }
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');
292
320
  const prompt = value['prompt'];
293
321
  if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
294
322
  throw new ScheduleLogError('after prompt must be non-empty and already trimmed');
@@ -300,6 +328,7 @@ function decodeAfterRecord(value) {
300
328
  return Object.freeze({
301
329
  id: decodeId(value['id']),
302
330
  kind: 'after',
331
+ ...(title === undefined ? {} : { title }),
303
332
  prompt,
304
333
  afterSeconds: afterSeconds,
305
334
  scheduledAt: decodeInstant(value['scheduledAt']),
@@ -307,9 +336,10 @@ function decodeAfterRecord(value) {
307
336
  }
308
337
  /** Decode the exact v1 absolute one-shot record shape. */
309
338
  function decodeAtRecord(value) {
310
- if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'scheduledAt'])) {
311
- throw new ScheduleLogError('at schedule must contain exactly id, kind, prompt, and scheduledAt');
312
- }
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');
313
343
  const prompt = value['prompt'];
314
344
  if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
315
345
  throw new ScheduleLogError('at prompt must be non-empty and already trimmed');
@@ -317,16 +347,17 @@ function decodeAtRecord(value) {
317
347
  return Object.freeze({
318
348
  id: decodeId(value['id']),
319
349
  kind: 'at',
350
+ ...(title === undefined ? {} : { title }),
320
351
  prompt,
321
352
  scheduledAt: decodeInstant(value['scheduledAt']),
322
353
  });
323
354
  }
324
355
  /** Decode the exact v1 fixed-rate record shape. */
325
356
  function decodeEveryRecord(value) {
326
- if (!isRecord(value)
327
- || !hasExactKeys(value, ['id', 'kind', 'prompt', 'everySeconds', 'scheduledAt'])) {
328
- throw new ScheduleLogError('every schedule must contain exactly id, kind, prompt, everySeconds, and scheduledAt');
329
- }
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');
330
361
  const prompt = value['prompt'];
331
362
  if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
332
363
  throw new ScheduleLogError('every prompt must be non-empty and already trimmed');
@@ -341,13 +372,206 @@ function decodeEveryRecord(value) {
341
372
  return Object.freeze({
342
373
  id: decodeId(value['id']),
343
374
  kind: 'every',
375
+ ...(title === undefined ? {} : { title }),
344
376
  prompt,
345
377
  everySeconds: everySeconds,
346
378
  scheduledAt: decodeInstant(value['scheduledAt']),
347
379
  });
348
380
  }
349
- /** Decode one current durable record variant by its exact discriminator. */
350
- function decodeScheduleRecord(value) {
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) {
351
575
  if (!isRecord(value))
352
576
  throw new ScheduleLogError('schedule record must be an object');
353
577
  switch (value['kind']) {
@@ -376,7 +600,7 @@ export function decodeScheduleChange(value) {
376
600
  return Object.freeze({
377
601
  version: SCHEDULE_CHANGE_VERSION,
378
602
  operation: 'create',
379
- schedule: decodeScheduleRecord(value['schedule']),
603
+ schedule: decodeLegacyScheduleRecord(value['schedule']),
380
604
  });
381
605
  case 'delete': {
382
606
  if (!hasExactKeys(value, ['version', 'operation', 'id'])) {
@@ -446,6 +670,286 @@ export function resolveEveryOccurrence(record, acceptedAt) {
446
670
  nextScheduledAt: new Date(next).toISOString(),
447
671
  });
448
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
+ }
449
953
  /** Apply one decoded dispatch to its exact active record. */
450
954
  function dispatchedRecord(record, change) {
451
955
  const hasAcceptedAt = 'acceptedAt' in change;
@@ -464,9 +968,8 @@ function dispatchedRecord(record, change) {
464
968
  /**
465
969
  * Apply already-decoded Schedule changes to one complete fold value.
466
970
  *
467
- * This is the single transition authority shared by full-log replay and the
468
- * incremental Session projection. One mutable Map/Set pair spans the whole
469
- * batch; the returned arrays are materialized and frozen once.
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.
470
973
  * @param folded - complete active records and used-id history before the changes.
471
974
  * @param changes - strictly decoded durable mutations in log order.
472
975
  * @returns the complete fold value after every mutation.
@@ -553,13 +1056,14 @@ export function allocateScheduleId(folded) {
553
1056
  }
554
1057
  /**
555
1058
  * Validate a model after rule and compute its durable target.
556
- * @param id - Already allocated session-local id.
1059
+ * @param id - Already allocated task id.
557
1060
  * @param prompt - Reminder content supplied at creation.
558
1061
  * @param afterSeconds - Requested positive delay.
559
- * @param now - Single creation-time wall-clock sample in epoch milliseconds.
1062
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1063
+ * @param title - Required task name supplied at creation.
560
1064
  * @returns Frozen durable after record.
561
1065
  */
562
- export function createAfterScheduleRecord(id, prompt, afterSeconds, now) {
1066
+ export function createAfterScheduleRecord(id, prompt, afterSeconds, now, title) {
563
1067
  const normalizedPrompt = prompt.trim();
564
1068
  if (normalizedPrompt.length === 0) {
565
1069
  throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
@@ -572,6 +1076,7 @@ export function createAfterScheduleRecord(id, prompt, afterSeconds, now) {
572
1076
  return Object.freeze({
573
1077
  id,
574
1078
  kind: 'after',
1079
+ title: scheduleTitle(title),
575
1080
  prompt: normalizedPrompt,
576
1081
  afterSeconds,
577
1082
  scheduledAt: futureInstant(target, now),
@@ -579,17 +1084,32 @@ export function createAfterScheduleRecord(id, prompt, afterSeconds, now) {
579
1084
  }
580
1085
  /**
581
1086
  * Validate an absolute selector and compute its sole durable UTC target.
582
- * @param id - Already allocated session-local id.
1087
+ * @param id - Already allocated task id.
583
1088
  * @param prompt - Reminder content supplied at creation.
584
1089
  * @param at - Explicit-offset instant or structured local calendar value.
585
- * @param now - Single creation-time wall-clock sample in epoch milliseconds.
1090
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1091
+ * @param title - Required task name supplied at creation.
586
1092
  * @returns Frozen durable absolute one-shot record.
587
1093
  */
588
- export function createAtScheduleRecord(id, prompt, at, now) {
1094
+ export function createAtScheduleRecord(id, prompt, at, now, title) {
589
1095
  const normalizedPrompt = prompt.trim();
590
1096
  if (normalizedPrompt.length === 0) {
591
1097
  throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
592
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) {
593
1113
  let target;
594
1114
  if (typeof at === 'string') {
595
1115
  target = parseOffsetInstant(at);
@@ -615,22 +1135,18 @@ export function createAtScheduleRecord(id, prompt, at, now) {
615
1135
  else {
616
1136
  throw new ScheduleInputError('invalid_rule', 'at must be an explicit-offset string or local calendar object.');
617
1137
  }
618
- return Object.freeze({
619
- id,
620
- kind: 'at',
621
- prompt: normalizedPrompt,
622
- scheduledAt: futureInstant(target, now),
623
- });
1138
+ return target;
624
1139
  }
625
1140
  /**
626
- * Validate a fixed-rate selector and compute its first creation-aligned target.
627
- * @param id - Already allocated session-local id.
1141
+ * Validate a fixed-rate selector and compute the first target of a new interval anchor.
1142
+ * @param id - Already allocated task id.
628
1143
  * @param prompt - Reminder content supplied at creation.
629
1144
  * @param everySeconds - Requested fixed safe-integer interval.
630
- * @param now - Single creation-time wall-clock sample in epoch milliseconds.
1145
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1146
+ * @param title - Required task name supplied at creation.
631
1147
  * @returns Frozen durable fixed-rate record.
632
1148
  */
633
- export function createEveryScheduleRecord(id, prompt, everySeconds, now) {
1149
+ export function createEveryScheduleRecord(id, prompt, everySeconds, now, title) {
634
1150
  const normalizedPrompt = prompt.trim();
635
1151
  if (normalizedPrompt.length === 0) {
636
1152
  throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
@@ -646,22 +1162,393 @@ export function createEveryScheduleRecord(id, prompt, everySeconds, now) {
646
1162
  return Object.freeze({
647
1163
  id,
648
1164
  kind: 'every',
1165
+ title: scheduleTitle(title),
649
1166
  prompt: normalizedPrompt,
650
1167
  everySeconds,
651
1168
  scheduledAt: futureInstant(target, now),
652
1169
  });
653
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
+ }
654
1541
  /**
655
1542
  * Derive one execution-local management view.
656
1543
  * @param record - Active durable record.
657
1544
  * @param now - Wall-clock sample used for its timing state.
658
- * @returns Complete session-local view.
1545
+ * @returns Complete Host delivery view.
659
1546
  */
660
1547
  export function scheduleView(record, now) {
661
1548
  return Object.freeze({
662
1549
  ...record,
663
1550
  state: now >= Date.parse(record.scheduledAt) ? 'overdue' : 'scheduled',
664
- deliveryMode: 'session-local',
1551
+ deliveryMode: 'host',
665
1552
  });
666
1553
  }
667
1554
  /**
@@ -679,11 +1566,11 @@ export function renderReminderFraming(record) {
679
1566
  ].join('\n');
680
1567
  }
681
1568
  /**
682
- * Render one injection-resistant fixed-rate batch in target and create order.
1569
+ * Render one injection-resistant recurring batch in the supplied order.
683
1570
  * @param reminders - Complete admitted batch with one latest occurrence per record.
684
1571
  * @returns Stable model-visible text whose dynamic payload is canonical JSON.
685
1572
  */
686
- export function renderEveryReminderBatchFraming(reminders) {
1573
+ export function renderRecurringReminderBatchFraming(reminders) {
687
1574
  const payload = reminders.map(({ record, occurrenceAt }) => ({
688
1575
  schedule_id: record.id,
689
1576
  occurrence_at: occurrenceAt,