@qilin-agent/schedule 0.0.0-stage → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,2271 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import z from "@qilin-agent/schemastery";
3
+ import { Service } from "@qilin-agent/kylin";
4
+ import { Remote, TypertRemoteService } from "@qilin-agent/typert-protocol";
5
+ import { createUserMessage } from "@qilin-agent/llm";
6
+ import { Temporal } from "@js-temporal/polyfill";
7
+ import { SessionId, SessionLogOffset } from "@qilin-agent/session";
8
+ import { z as z$1 } from "zod";
9
+ import { MessageId } from "@qilin-agent/llm/brand";
10
+ import { defineDomain, domainTable } from "@qilin-agent/storage-domain";
11
+ import { isDeepStrictEqual } from "node:util";
12
+ //#region lib/types/domain.js
13
+ /**
14
+ * Strict Schedule decoding, replay, time validation, and framing.
15
+ * @module @qilin-agent/schedule
16
+ */
17
+ /** Durable Schedule protocol version implemented by this package. */
18
+ const SCHEDULE_CHANGE_VERSION = 1;
19
+ /** Fixed v1 lower bound for a fixed-rate reminder. */
20
+ const MIN_EVERY_INTERVAL_SECONDS = 60;
21
+ /** Fixed v1 upper bound for a stored task title. */
22
+ const MAX_TITLE_LENGTH = 120;
23
+ const MIN_FOUR_DIGIT_YEAR_MS = Date.parse("0001-01-01T00:00:00.000Z");
24
+ const MAX_FOUR_DIGIT_YEAR_MS = Date.parse("9999-12-31T23:59:59.999Z");
25
+ const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/;
26
+ const OFFSET_INSTANT = new RegExp(String.raw`^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})` + String.raw`T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})` + String.raw`(?:\.(?<fraction>\d{1,3}))?(?<zone>Z|(?<sign>[+-])` + String.raw`(?<offsetHour>\d{2}):(?<offsetMinute>\d{2}))$`);
27
+ const LOCAL_DATE = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;
28
+ const LOCAL_TIME = /^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?:\.(?<fraction>\d{1,3}))?$/;
29
+ const LOCAL_CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?$/;
30
+ const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
31
+ /** Error from malformed or transition-invalid durable Schedule data. */
32
+ var ScheduleLogError = class extends Error {
33
+ /** Stable machine-readable error code. */
34
+ code = "corrupt_schedule_log";
35
+ /**
36
+ * Construct a durable-log failure.
37
+ * @param message - Package-specific violated invariant.
38
+ */
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "ScheduleLogError";
42
+ }
43
+ };
44
+ /** Error from a model-supplied Schedule rule that cannot become a record. */
45
+ var ScheduleInputError = class extends Error {
46
+ /** Stable public Schedule input code. */
47
+ code;
48
+ /**
49
+ * Construct a stable input failure.
50
+ * @param code - Public Schedule error discriminator.
51
+ * @param message - Stable public diagnostic.
52
+ * @param options - Optional contained implementation cause.
53
+ */
54
+ constructor(code, message, options) {
55
+ super(message, options);
56
+ this.name = "ScheduleInputError";
57
+ this.code = code;
58
+ }
59
+ };
60
+ /**
61
+ * Brand a raw session-local id without changing its runtime value.
62
+ * @param value - Raw session-local id.
63
+ * @returns The same string with the Schedule brand.
64
+ */
65
+ function ScheduleId(value) {
66
+ return value;
67
+ }
68
+ /** Whether an unknown value is a non-array object. */
69
+ function isRecord(value) {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+ /** Require exactly the named durable object keys. */
73
+ function hasExactKeys(value, expected) {
74
+ const allowed = new Set(expected);
75
+ return expected.every((key) => key in value) && Object.keys(value).every((key) => allowed.has(key));
76
+ }
77
+ /** Require the named durable keys while admitting further optional members. */
78
+ function hasExactKeysWithOptional(value, required, optional) {
79
+ const allowed = new Set([...required, ...optional]);
80
+ return required.every((key) => key in value) && Object.keys(value).every((key) => allowed.has(key));
81
+ }
82
+ /** Stable diagnostic for a title that is missing or empty after trimming. */
83
+ const REQUIRED_TITLE_MESSAGE = "title is required and must be non-empty after trimming.";
84
+ /**
85
+ * Validate the title supplied at creation.
86
+ *
87
+ * Creation requires an explicit title: a missing, blank-after-trim, or over-long
88
+ * value throws instead of deriving a name from the instruction.
89
+ * @param title - Task name supplied at creation.
90
+ * @returns The trimmed title; an invalid title throws ScheduleInputError.
91
+ */
92
+ function scheduleTitle(title) {
93
+ if (typeof title !== "string" || title.trim().length === 0) throw new ScheduleInputError("invalid_prompt", REQUIRED_TITLE_MESSAGE);
94
+ const normalized = title.trim();
95
+ if (normalized.length > 120) throw new ScheduleInputError("invalid_prompt", `title must be at most 120 characters.`);
96
+ return normalized;
97
+ }
98
+ /**
99
+ * Validate one required stored title at the durable boundary.
100
+ *
101
+ * Only records written after titles became required are read: a missing,
102
+ * blank-after-trim, untrimmed, or over-long stored title is invalid, and no name
103
+ * is derived from the instruction.
104
+ * @param value - Untrusted durable title field.
105
+ * @returns The stored title; an invalid title throws ScheduleLogError.
106
+ */
107
+ function decodeStoredTitle(value) {
108
+ if (typeof value !== "string" || value.trim().length === 0) throw new ScheduleLogError(REQUIRED_TITLE_MESSAGE);
109
+ if (value.length > 120) throw new ScheduleLogError(`title must be at most 120 characters`);
110
+ if (value.trim() !== value) throw new ScheduleLogError("title must be a trimmed string");
111
+ return value;
112
+ }
113
+ /**
114
+ * Decode the required stored title of one durable Host record, then require its exact key set.
115
+ *
116
+ * The title decodes first so that a record without the key reports the title
117
+ * diagnostic rather than the record's required-key list.
118
+ * @param value - Untrusted durable record already known to be an object.
119
+ * @param expected - Exact keys the record must carry, including `title`.
120
+ * @param message - Diagnostic naming the record's required keys.
121
+ * @returns The stored title; an invalid title or key set throws ScheduleLogError.
122
+ */
123
+ function decodeRecordTitle(value, expected, message) {
124
+ const title = decodeStoredTitle(value["title"]);
125
+ if (!hasExactKeys(value, expected)) throw new ScheduleLogError(message);
126
+ return title;
127
+ }
128
+ /**
129
+ * Decode the stored title of one historical `schedule/change` create record.
130
+ *
131
+ * A version-1 event written before names existed has no `title` member, so the
132
+ * key set is required without it and the absent member decodes as undefined. A
133
+ * present title stays subject to the canonical stored form.
134
+ * @param value - Untrusted durable record already known to be an object.
135
+ * @param expected - Exact keys the record may carry; `title` is optional within them.
136
+ * @param message - Diagnostic naming the record's keys.
137
+ * @returns The stored title, or undefined when the historical record predates it.
138
+ */
139
+ function decodeHistoricalRecordTitle(value, expected, message) {
140
+ if (!hasExactKeysWithOptional(value, expected.filter((key) => key !== "title"), ["title"])) throw new ScheduleLogError(message);
141
+ return value["title"] === void 0 ? void 0 : decodeStoredTitle(value["title"]);
142
+ }
143
+ /** Validate one stable session-local id at the durable boundary. */
144
+ function decodeId(value) {
145
+ if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new ScheduleLogError("schedule id must be a non-empty string without surrounding whitespace");
146
+ return ScheduleId(value);
147
+ }
148
+ /** Validate one canonical four-digit-year UTC instant. */
149
+ function decodeInstant(value) {
150
+ if (typeof value !== "string" || !UTC_INSTANT.test(value)) throw new ScheduleLogError("scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant");
151
+ const epoch = Date.parse(value);
152
+ if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) throw new ScheduleLogError("scheduledAt is not a real UTC calendar instant");
153
+ return value;
154
+ }
155
+ /** Read one required named regular-expression group as a number. */
156
+ function groupNumber(groups, name) {
157
+ const value = groups[name];
158
+ /* v8 ignore next -- successful fixed regexes always provide every requested group. */
159
+ if (value === void 0) throw new ScheduleInputError("invalid_rule", "The at value has an invalid shape.");
160
+ return Number(value);
161
+ }
162
+ /** Convert exact calendar fields to a UTC-shaped epoch while rejecting normalization. */
163
+ function calendarEpoch(parts) {
164
+ const value = /* @__PURE__ */ new Date(0);
165
+ value.setUTCHours(0, 0, 0, 0);
166
+ value.setUTCFullYear(parts.year, parts.month - 1, parts.day);
167
+ value.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
168
+ const epoch = value.getTime();
169
+ if (!Number.isFinite(epoch) || value.getUTCFullYear() !== parts.year || value.getUTCMonth() + 1 !== parts.month || value.getUTCDate() !== parts.day || value.getUTCHours() !== parts.hour || value.getUTCMinutes() !== parts.minute || value.getUTCSeconds() !== parts.second || value.getUTCMilliseconds() !== parts.millisecond) throw new ScheduleInputError("invalid_rule", "The at value must be a real ISO calendar date and time.");
170
+ return epoch;
171
+ }
172
+ /** Normalize an optional one-to-three digit fractional second to milliseconds. */
173
+ function milliseconds(value) {
174
+ return value === void 0 ? 0 : Number(value.padEnd(3, "0"));
175
+ }
176
+ /** Require a safe, representable, strictly future UTC target. */
177
+ function futureInstant(epoch, now) {
178
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(epoch) || epoch < MIN_FOUR_DIGIT_YEAR_MS || epoch > MAX_FOUR_DIGIT_YEAR_MS) throw new ScheduleInputError("time_out_of_range", "The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.");
179
+ if (epoch <= now) throw new ScheduleInputError("not_future", "The scheduled time must be strictly in the future.");
180
+ const instant = new Date(epoch).toISOString();
181
+ /* v8 ignore next -- an in-range integral Date always formats as the canonical UTC profile. */
182
+ if (!UTC_INSTANT.test(instant)) throw new ScheduleInputError("time_out_of_range", "The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.");
183
+ return instant;
184
+ }
185
+ /** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */
186
+ function parseOffsetInstant(value) {
187
+ const groups = OFFSET_INSTANT.exec(value)?.groups;
188
+ if (groups === void 0) throw new ScheduleInputError("invalid_rule", "at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.");
189
+ const parts = {
190
+ year: groupNumber(groups, "year"),
191
+ month: groupNumber(groups, "month"),
192
+ day: groupNumber(groups, "day"),
193
+ hour: groupNumber(groups, "hour"),
194
+ minute: groupNumber(groups, "minute"),
195
+ second: groupNumber(groups, "second"),
196
+ millisecond: milliseconds(groups["fraction"])
197
+ };
198
+ if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) throw new ScheduleInputError("invalid_rule", "The at value must be a real ISO calendar date and time.");
199
+ const localEpoch = calendarEpoch(parts);
200
+ if (groups["zone"] === "Z") return localEpoch;
201
+ const offsetHour = groupNumber(groups, "offsetHour");
202
+ const offsetMinute = groupNumber(groups, "offsetMinute");
203
+ if (offsetHour > 23 || offsetMinute > 59 || groups["sign"] === "-" && offsetHour === 0 && offsetMinute === 0) throw new ScheduleInputError("invalid_rule", "The at numeric offset is invalid.");
204
+ return localEpoch - (groups["sign"] === "+" ? 1 : -1) * (offsetHour * 60 + offsetMinute) * 6e4;
205
+ }
206
+ /**
207
+ * Validate and canonicalize one raw IANA time-zone selector.
208
+ * @param value - Candidate `UTC` or IANA Area/Location name.
209
+ * @returns The runtime's canonical IANA name.
210
+ */
211
+ function canonicalizeTimeZone(value) {
212
+ if (value.length === 0 || value.trim() !== value || value !== "UTC" && !IANA_ZONE.test(value)) throw new ScheduleInputError("invalid_time_zone", "time_zone must be UTC or a valid IANA Area/Location name.");
213
+ let canonical;
214
+ try {
215
+ canonical = new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone;
216
+ } catch (error) {
217
+ throw new ScheduleInputError("invalid_time_zone", "time_zone must be UTC or a valid IANA Area/Location name.", { cause: error });
218
+ }
219
+ /* v8 ignore next -- Intl returns the requested canonical zone or an IANA canonical alias. */
220
+ if (canonical !== "UTC" && !IANA_ZONE.test(canonical)) throw new ScheduleInputError("invalid_time_zone", "time_zone must resolve to UTC or an IANA Area/Location name.");
221
+ return canonical;
222
+ }
223
+ /** Parse strict local calendar fields without consulting a process time zone. */
224
+ function parseLocalAt(value) {
225
+ const dateMatch = LOCAL_DATE.exec(value.date);
226
+ const timeMatch = LOCAL_TIME.exec(value.time);
227
+ const date = dateMatch?.groups;
228
+ const time = timeMatch?.groups;
229
+ if (date === void 0 || time === void 0) throw new ScheduleInputError("invalid_rule", "Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.");
230
+ const parts = {
231
+ year: groupNumber(date, "year"),
232
+ month: groupNumber(date, "month"),
233
+ day: groupNumber(date, "day"),
234
+ hour: groupNumber(time, "hour"),
235
+ minute: groupNumber(time, "minute"),
236
+ second: groupNumber(time, "second"),
237
+ millisecond: milliseconds(time["fraction"])
238
+ };
239
+ if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) throw new ScheduleInputError("invalid_rule", "The local at value must be a real ISO calendar date and time.");
240
+ calendarEpoch(parts);
241
+ return parts;
242
+ }
243
+ /** Resolve only the earlier overlap instant; a gap fails the local-field round trip. */
244
+ function localInstant(local, timeZone) {
245
+ const zoned = local.toZonedDateTime(timeZone, { disambiguation: "earlier" });
246
+ return zoned.toPlainDateTime().equals(local) ? zoned.epochMilliseconds : void 0;
247
+ }
248
+ /** Resolve a local one-shot, rejecting nonexistent wall-clock times. */
249
+ function resolveLocalInstant(parts, timeZone) {
250
+ const target = localInstant(Temporal.PlainDateTime.from(parts, { overflow: "reject" }), timeZone);
251
+ if (target === void 0) throw new ScheduleInputError("invalid_rule", "The local at time does not exist in the selected time zone.");
252
+ return target;
253
+ }
254
+ /** Decode the exact v1 after record shape. */
255
+ function decodeAfterRecord(value) {
256
+ /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
257
+ if (!isRecord(value)) throw new ScheduleLogError("after schedule must be an object");
258
+ const title = decodeHistoricalRecordTitle(value, [
259
+ "id",
260
+ "kind",
261
+ "title",
262
+ "prompt",
263
+ "afterSeconds",
264
+ "scheduledAt"
265
+ ], "after schedule must contain exactly id, kind, title, prompt, afterSeconds, and scheduledAt");
266
+ const prompt = value["prompt"];
267
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.trim() !== prompt) throw new ScheduleLogError("after prompt must be non-empty and already trimmed");
268
+ const afterSeconds = value["afterSeconds"];
269
+ if (!Number.isSafeInteger(afterSeconds) || afterSeconds <= 0) throw new ScheduleLogError("afterSeconds must be a positive safe integer");
270
+ return Object.freeze({
271
+ id: decodeId(value["id"]),
272
+ kind: "after",
273
+ ...title === void 0 ? {} : { title },
274
+ prompt,
275
+ afterSeconds,
276
+ scheduledAt: decodeInstant(value["scheduledAt"])
277
+ });
278
+ }
279
+ /** Decode the exact v1 absolute one-shot record shape. */
280
+ function decodeAtRecord(value) {
281
+ /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
282
+ if (!isRecord(value)) throw new ScheduleLogError("at schedule must be an object");
283
+ const title = decodeHistoricalRecordTitle(value, [
284
+ "id",
285
+ "kind",
286
+ "title",
287
+ "prompt",
288
+ "scheduledAt"
289
+ ], "at schedule must contain exactly id, kind, title, prompt, and scheduledAt");
290
+ const prompt = value["prompt"];
291
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.trim() !== prompt) throw new ScheduleLogError("at prompt must be non-empty and already trimmed");
292
+ return Object.freeze({
293
+ id: decodeId(value["id"]),
294
+ kind: "at",
295
+ ...title === void 0 ? {} : { title },
296
+ prompt,
297
+ scheduledAt: decodeInstant(value["scheduledAt"])
298
+ });
299
+ }
300
+ /** Decode the exact v1 fixed-rate record shape. */
301
+ function decodeEveryRecord(value) {
302
+ /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
303
+ if (!isRecord(value)) throw new ScheduleLogError("every schedule must be an object");
304
+ const title = decodeHistoricalRecordTitle(value, [
305
+ "id",
306
+ "kind",
307
+ "title",
308
+ "prompt",
309
+ "everySeconds",
310
+ "scheduledAt"
311
+ ], "every schedule must contain exactly id, kind, title, prompt, everySeconds, and scheduledAt");
312
+ const prompt = value["prompt"];
313
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.trim() !== prompt) throw new ScheduleLogError("every prompt must be non-empty and already trimmed");
314
+ const everySeconds = value["everySeconds"];
315
+ const interval = typeof everySeconds === "number" ? everySeconds * 1e3 : NaN;
316
+ if (!Number.isSafeInteger(everySeconds) || everySeconds < 60 || !Number.isSafeInteger(interval)) throw new ScheduleLogError(`everySeconds must be a safe integer of at least 60`);
317
+ return Object.freeze({
318
+ id: decodeId(value["id"]),
319
+ kind: "every",
320
+ ...title === void 0 ? {} : { title },
321
+ prompt,
322
+ everySeconds,
323
+ scheduledAt: decodeInstant(value["scheduledAt"])
324
+ });
325
+ }
326
+ /**
327
+ * Parse one strict local clock time shared by the wall-clock selectors.
328
+ * @param value - Candidate `HH:mm:ss` time with optional one-to-three fractional digits.
329
+ * @param selector - Public selector name used in the diagnostic.
330
+ * @returns The parsed plain time; malformed input throws ScheduleInputError.
331
+ */
332
+ function localClockTime(value, selector) {
333
+ if (!LOCAL_CLOCK_TIME.test(value)) throw new ScheduleInputError("invalid_rule", `${selector}.time must use HH:mm:ss with optional 1-3 fractional digits, without leap seconds or 24:00.`);
334
+ return Temporal.PlainTime.from(value);
335
+ }
336
+ /** Parse strictly before Temporal can constrain or coerce the supplied time. */
337
+ function dailyTime(value) {
338
+ return localClockTime(value, "daily");
339
+ }
340
+ /**
341
+ * Parse one strict local clock time accepted by the weekly selector.
342
+ * @param value - Candidate `HH:mm:ss` time with optional one-to-three fractional digits.
343
+ * @returns The parsed plain time; malformed input throws ScheduleInputError.
344
+ */
345
+ function weeklyTime(value) {
346
+ return localClockTime(value, "weekly");
347
+ }
348
+ /** Fail already-typed durable values without wrapping the stored-data diagnostic. */
349
+ function rethrowLogError(error) {
350
+ /* v8 ignore next -- every wrapped call raises ScheduleInputError, so this pass-through never fires. */
351
+ if (error instanceof ScheduleLogError) throw error;
352
+ throw new ScheduleLogError(String(error));
353
+ }
354
+ /** Decode a Host daily rule without re-resolving its committed UTC target. */
355
+ function decodeDailyRecord(value) {
356
+ const title = decodeRecordTitle(value, [
357
+ "id",
358
+ "kind",
359
+ "title",
360
+ "prompt",
361
+ "time",
362
+ "timeZone",
363
+ "scheduledAt"
364
+ ], "daily schedule must contain exactly id, kind, title, prompt, time, timeZone, and scheduledAt");
365
+ const prompt = value["prompt"];
366
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.trim() !== prompt) throw new ScheduleLogError("daily prompt must be non-empty and already trimmed");
367
+ const time = value["time"];
368
+ const timeZone = value["timeZone"];
369
+ if (typeof time !== "string" || typeof timeZone !== "string") throw new ScheduleLogError("daily time and timeZone must be strings");
370
+ let normalized;
371
+ try {
372
+ normalized = dailyTime(time).toString({ fractionalSecondDigits: 3 });
373
+ canonicalizeTimeZone(timeZone);
374
+ } catch (error) {
375
+ rethrowLogError(error);
376
+ }
377
+ if (time !== normalized) throw new ScheduleLogError("daily time must be normalized to HH:mm:ss.SSS");
378
+ return Object.freeze({
379
+ id: decodeId(value["id"]),
380
+ kind: "daily",
381
+ title,
382
+ prompt,
383
+ time,
384
+ timeZone,
385
+ scheduledAt: decodeInstant(value["scheduledAt"])
386
+ });
387
+ }
388
+ /**
389
+ * Normalize the explicit ISO weekday set of one weekly rule.
390
+ * @param weekdays - Untrusted candidate weekday values.
391
+ * @returns Frozen unique ascending weekdays from 1 (Monday) through 7 (Sunday).
392
+ */
393
+ function normalizeWeekdays(weekdays) {
394
+ if (!Array.isArray(weekdays) || weekdays.length === 0) throw new ScheduleInputError("invalid_rule", "weekly.weekdays must be a non-empty array of ISO weekday numbers.");
395
+ const unique = /* @__PURE__ */ new Set();
396
+ for (const weekday of weekdays) {
397
+ if (typeof weekday !== "number" || !Number.isInteger(weekday) || weekday < 1 || weekday > 7) throw new ScheduleInputError("invalid_rule", "Each weekly.weekdays entry must be an integer from 1 (Monday) through 7 (Sunday).");
398
+ if (unique.has(weekday)) throw new ScheduleInputError("invalid_rule", `weekly.weekdays must not repeat weekday ${weekday}.`);
399
+ unique.add(weekday);
400
+ }
401
+ const ordered = [...unique].sort((left, right) => left - right);
402
+ Object.freeze(ordered);
403
+ return ordered;
404
+ }
405
+ /**
406
+ * Decode a Host weekly rule without re-resolving its committed UTC target.
407
+ * @param value - Untrusted durable record already identified as weekly.
408
+ * @returns Detached frozen weekly record; malformed fields throw ScheduleLogError.
409
+ */
410
+ function decodeWeeklyRecord(value) {
411
+ const title = decodeRecordTitle(value, [
412
+ "id",
413
+ "kind",
414
+ "title",
415
+ "prompt",
416
+ "time",
417
+ "timeZone",
418
+ "weekdays",
419
+ "scheduledAt"
420
+ ], "weekly schedule must contain exactly id, kind, title, prompt, time, timeZone, weekdays, and scheduledAt");
421
+ const prompt = value["prompt"];
422
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.trim() !== prompt) throw new ScheduleLogError("weekly prompt must be non-empty and already trimmed");
423
+ const time = value["time"];
424
+ const timeZone = value["timeZone"];
425
+ if (typeof time !== "string" || typeof timeZone !== "string") throw new ScheduleLogError("weekly time and timeZone must be strings");
426
+ try {
427
+ canonicalizeTimeZone(timeZone);
428
+ } catch (error) {
429
+ rethrowLogError(error);
430
+ }
431
+ let normalized;
432
+ try {
433
+ normalized = weeklyTime(time).toString({ fractionalSecondDigits: 3 });
434
+ } catch (error) {
435
+ rethrowLogError(error);
436
+ }
437
+ if (time !== normalized) throw new ScheduleLogError("weekly time must be normalized to HH:mm:ss.SSS");
438
+ const stored = value["weekdays"];
439
+ let weekdays;
440
+ try {
441
+ weekdays = normalizeWeekdays(stored);
442
+ } catch (error) {
443
+ rethrowLogError(error);
444
+ }
445
+ if (weekdays.some((weekday, index) => weekday !== stored[index])) throw new ScheduleLogError("weekly weekdays must be normalized to unique ascending ISO weekday numbers");
446
+ return Object.freeze({
447
+ id: decodeId(value["id"]),
448
+ kind: "weekly",
449
+ title,
450
+ prompt,
451
+ time,
452
+ timeZone,
453
+ weekdays,
454
+ scheduledAt: decodeInstant(value["scheduledAt"])
455
+ });
456
+ }
457
+ /**
458
+ * Decode a Host cron rule without re-resolving its committed UTC target.
459
+ * @param value - Untrusted durable record already identified as cron.
460
+ * @returns Detached frozen cron record; malformed fields throw ScheduleLogError.
461
+ */
462
+ function decodeCronRecord(value) {
463
+ const title = decodeRecordTitle(value, [
464
+ "id",
465
+ "kind",
466
+ "title",
467
+ "prompt",
468
+ "expression",
469
+ "timeZone",
470
+ "scheduledAt"
471
+ ], "cron schedule must contain exactly id, kind, title, prompt, expression, timeZone, and scheduledAt");
472
+ const prompt = value["prompt"];
473
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.trim() !== prompt) throw new ScheduleLogError("cron prompt must be non-empty and already trimmed");
474
+ const expression = value["expression"];
475
+ const timeZone = value["timeZone"];
476
+ if (typeof expression !== "string" || typeof timeZone !== "string") throw new ScheduleLogError("cron expression and timeZone must be strings");
477
+ try {
478
+ canonicalizeTimeZone(timeZone);
479
+ } catch (error) {
480
+ rethrowLogError(error);
481
+ }
482
+ let canonical;
483
+ try {
484
+ canonical = parseCronExpression(expression).expression;
485
+ } catch (error) {
486
+ rethrowLogError(error);
487
+ }
488
+ if (expression !== canonical) throw new ScheduleLogError("cron expression must be canonical");
489
+ return Object.freeze({
490
+ id: decodeId(value["id"]),
491
+ kind: "cron",
492
+ title,
493
+ prompt,
494
+ expression,
495
+ timeZone,
496
+ scheduledAt: decodeInstant(value["scheduledAt"])
497
+ });
498
+ }
499
+ /**
500
+ * Decode a current Host task record, preserving its committed target and stored zone spelling.
501
+ *
502
+ * Every variant of a stored Host task carries its title, so the historical
503
+ * one-shot variants are re-checked for that member after their shape decodes.
504
+ * @param value - Untrusted durable JSON record.
505
+ * @returns Detached frozen record; malformed fields throw ScheduleLogError.
506
+ */
507
+ function decodeScheduleRecord(value) {
508
+ if (isRecord(value) && value["kind"] === "daily") return decodeDailyRecord(value);
509
+ if (isRecord(value) && value["kind"] === "weekly") return decodeWeeklyRecord(value);
510
+ if (isRecord(value) && value["kind"] === "cron") return decodeCronRecord(value);
511
+ const record = decodeLegacyScheduleRecord(value);
512
+ if (record.title === void 0) throw new ScheduleLogError(REQUIRED_TITLE_MESSAGE);
513
+ return record;
514
+ }
515
+ /**
516
+ * Decode only the variants admitted by historical version-1 Session events.
517
+ *
518
+ * A record written before titles existed decodes without that member, so the
519
+ * fold keeps reading a log that the current creation path could not write.
520
+ * @param value - Untrusted durable JSON record from a Session event.
521
+ * @returns Detached frozen record, without a title when the event omitted it.
522
+ */
523
+ function decodeLegacyScheduleRecord(value) {
524
+ if (!isRecord(value)) throw new ScheduleLogError("schedule record must be an object");
525
+ switch (value["kind"]) {
526
+ case "after": return decodeAfterRecord(value);
527
+ case "at": return decodeAtRecord(value);
528
+ case "every": return decodeEveryRecord(value);
529
+ default: throw new ScheduleLogError("v1 schedule kind must be \"after\", \"at\", or \"every\"");
530
+ }
531
+ }
532
+ /**
533
+ * Decode one strict version-1 `schedule/change` payload.
534
+ * @param value - Untrusted durable JSON value.
535
+ * @returns Detached, frozen Schedule change.
536
+ */
537
+ function decodeScheduleChange(value) {
538
+ if (!isRecord(value)) throw new ScheduleLogError("schedule/change payload must be an object");
539
+ if (value["version"] !== 1) throw new ScheduleLogError("schedule/change version must be 1");
540
+ switch (value["operation"]) {
541
+ case "create":
542
+ if (!hasExactKeys(value, [
543
+ "version",
544
+ "operation",
545
+ "schedule"
546
+ ])) throw new ScheduleLogError("schedule create must contain exactly version, operation, and schedule");
547
+ return Object.freeze({
548
+ version: 1,
549
+ operation: "create",
550
+ schedule: decodeLegacyScheduleRecord(value["schedule"])
551
+ });
552
+ case "delete":
553
+ if (!hasExactKeys(value, [
554
+ "version",
555
+ "operation",
556
+ "id"
557
+ ])) throw new ScheduleLogError("schedule delete must contain exactly version, operation, and id");
558
+ return Object.freeze({
559
+ version: 1,
560
+ operation: "delete",
561
+ id: decodeId(value["id"])
562
+ });
563
+ case "dispatch":
564
+ if (hasExactKeys(value, [
565
+ "version",
566
+ "operation",
567
+ "id"
568
+ ])) return Object.freeze({
569
+ version: 1,
570
+ operation: "dispatch",
571
+ id: decodeId(value["id"])
572
+ });
573
+ if (hasExactKeys(value, [
574
+ "version",
575
+ "operation",
576
+ "id",
577
+ "acceptedAt"
578
+ ])) return Object.freeze({
579
+ version: 1,
580
+ operation: "dispatch",
581
+ id: decodeId(value["id"]),
582
+ acceptedAt: decodeInstant(value["acceptedAt"])
583
+ });
584
+ throw new ScheduleLogError("schedule dispatch must contain id and optional acceptedAt only");
585
+ default: throw new ScheduleLogError("schedule/change operation must be create, delete, or dispatch");
586
+ }
587
+ }
588
+ /**
589
+ * Resolve one fixed-rate decision without enumerating missed occurrences.
590
+ * @param record - Active record whose target is the earliest unaccepted occurrence.
591
+ * @param acceptedAt - Wall-clock decision time in epoch milliseconds.
592
+ * @returns The latest due occurrence and first strictly future target, if representable.
593
+ */
594
+ function resolveEveryOccurrence(record, acceptedAt) {
595
+ const target = Date.parse(record.scheduledAt);
596
+ const interval = record.everySeconds * 1e3;
597
+ if (!Number.isSafeInteger(acceptedAt) || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) throw new ScheduleLogError("every acceptedAt must be a representable four-digit-year instant");
598
+ if (!Number.isSafeInteger(interval) || interval <= 0) throw new ScheduleLogError("every interval milliseconds must be a positive safe integer");
599
+ if (acceptedAt < target) throw new ScheduleLogError("every dispatch cannot precede the active scheduledAt");
600
+ const occurrence = target + Math.floor((acceptedAt - target) / interval) * interval;
601
+ /* v8 ignore next -- bounded operands and a quotient-derived product stay safe. */
602
+ if (!Number.isSafeInteger(occurrence) || occurrence < target || occurrence > acceptedAt) throw new ScheduleLogError("every occurrence arithmetic must stay within the accepted interval");
603
+ const occurrenceAt = new Date(occurrence).toISOString();
604
+ const next = occurrence + interval;
605
+ if (!Number.isSafeInteger(next) || next > MAX_FOUR_DIGIT_YEAR_MS) return Object.freeze({ occurrenceAt });
606
+ return Object.freeze({
607
+ occurrenceAt,
608
+ nextScheduledAt: new Date(next).toISOString()
609
+ });
610
+ }
611
+ /** Project an explicit instant into a calendar date in the rule's zone. */
612
+ function localDate(epoch, timeZone) {
613
+ return Temporal.Instant.fromEpochMilliseconds(epoch).toZonedDateTimeISO(timeZone).toPlainDate();
614
+ }
615
+ /** Skip every local date; used by a rule that selects by time alone. */
616
+ const EVERY_DATE = () => true;
617
+ /** Select the explicit ISO weekdays of one normalized weekly rule. */
618
+ function weekdaySet(weekdays) {
619
+ const selected = new Set(weekdays);
620
+ return (date) => selected.has(date.dayOfWeek);
621
+ }
622
+ /** Find the first actual occurrence after now and, when supplied, after a delivered date. */
623
+ function nextMatchingTarget(time, timeZone, now, matches, afterDate) {
624
+ let date = localDate(now, timeZone);
625
+ const lastDate = localDate(MAX_FOUR_DIGIT_YEAR_MS, "UTC").add({ days: 1 });
626
+ if (afterDate !== void 0 && Temporal.PlainDate.compare(date, afterDate) <= 0) date = afterDate.add({ days: 1 });
627
+ for (; Temporal.PlainDate.compare(date, lastDate) <= 0; date = date.add({ days: 1 })) {
628
+ if (!matches(date)) continue;
629
+ const target = localInstant(date.toPlainDateTime(time), timeZone);
630
+ if (target === void 0) continue;
631
+ if (target > MAX_FOUR_DIGIT_YEAR_MS) return void 0;
632
+ if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > now) return new Date(target).toISOString();
633
+ }
634
+ }
635
+ /** Find the latest actual occurrence at or before a decision without leaving the committed floor. */
636
+ function latestDueOccurrence(timeZone, matches, time, acceptedAt, savedTarget) {
637
+ let date = localDate(acceptedAt, "UTC").add({ days: 1 });
638
+ for (;; date = date.subtract({ days: 1 })) {
639
+ if (!matches(date)) continue;
640
+ const candidate = localInstant(date.toPlainDateTime(time), timeZone);
641
+ if (candidate === void 0 || candidate > acceptedAt) continue;
642
+ return Math.max(candidate, savedTarget);
643
+ }
644
+ }
645
+ /** Resolve a wall-clock rule only when its decision is at a representable four-digit-year instant. */
646
+ function acceptedDecision(selector, acceptedAt) {
647
+ if (!Number.isSafeInteger(acceptedAt) || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) throw new ScheduleLogError(`${selector} acceptedAt must be a representable four-digit-year instant`);
648
+ return acceptedAt;
649
+ }
650
+ /** Find the first actual daily occurrence after now and, when supplied, after a delivered date. */
651
+ function nextDailyTarget(time, timeZone, now, afterDate) {
652
+ return nextMatchingTarget(time, timeZone, now, EVERY_DATE, afterDate);
653
+ }
654
+ /**
655
+ * Resolve a daily decision near the decision's local date, not across its missed history.
656
+ * @param record - Daily rule with a committed earliest unaccepted UTC target.
657
+ * @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.
658
+ * @returns Latest actual due occurrence and the next future occurrence on a later local date.
659
+ */
660
+ function resolveDailyOccurrence(record, acceptedAt) {
661
+ const decision = acceptedDecision("daily", acceptedAt);
662
+ const savedTarget = Date.parse(record.scheduledAt);
663
+ if (decision < savedTarget) throw new ScheduleLogError("daily dispatch cannot precede the active scheduledAt");
664
+ const time = dailyTime(record.time);
665
+ const occurrence = latestDueOccurrence(record.timeZone, EVERY_DATE, time, decision, savedTarget);
666
+ const occurrenceAt = new Date(occurrence).toISOString();
667
+ const nextScheduledAt = nextDailyTarget(time, record.timeZone, decision, localDate(occurrence, record.timeZone));
668
+ return Object.freeze(nextScheduledAt === void 0 ? { occurrenceAt } : {
669
+ occurrenceAt,
670
+ nextScheduledAt
671
+ });
672
+ }
673
+ /**
674
+ * Find the first actual weekly occurrence after now and, when supplied, after a delivered date.
675
+ * @param time - Normalized local clock time of the rule.
676
+ * @param timeZone - Explicit zone interpreting that clock time.
677
+ * @param weekdays - Normalized explicit ISO weekdays of the rule.
678
+ * @param now - Explicit decision time in epoch milliseconds.
679
+ * @param afterDate - Local date of the delivered occurrence, excluded from the search.
680
+ * @returns The first strictly future target, or exhaustion as undefined.
681
+ */
682
+ function nextWeeklyTarget(time, timeZone, weekdays, now, afterDate) {
683
+ return nextMatchingTarget(time, timeZone, now, weekdaySet(weekdays), afterDate);
684
+ }
685
+ /**
686
+ * Resolve a weekly decision near the decision's local date, not across its missed history.
687
+ * @param record - Weekly rule with a committed earliest unaccepted UTC target.
688
+ * @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.
689
+ * @returns Latest actual due occurrence and the next future occurrence on a selected weekday.
690
+ */
691
+ function resolveWeeklyOccurrence(record, acceptedAt) {
692
+ const decision = acceptedDecision("weekly", acceptedAt);
693
+ const savedTarget = Date.parse(record.scheduledAt);
694
+ if (decision < savedTarget) throw new ScheduleLogError("weekly dispatch cannot precede the active scheduledAt");
695
+ const time = weeklyTime(record.time);
696
+ const weekdays = normalizeWeekdays(record.weekdays);
697
+ const occurrence = latestDueOccurrence(record.timeZone, weekdaySet(weekdays), time, decision, savedTarget);
698
+ const occurrenceAt = new Date(occurrence).toISOString();
699
+ const nextScheduledAt = nextWeeklyTarget(time, record.timeZone, weekdays, decision, localDate(occurrence, record.timeZone));
700
+ return Object.freeze(nextScheduledAt === void 0 ? { occurrenceAt } : {
701
+ occurrenceAt,
702
+ nextScheduledAt
703
+ });
704
+ }
705
+ /**
706
+ * Build the local-date predicate of one parsed cron rule under Vixie day-of-month/day-of-week semantics.
707
+ *
708
+ * The star flag selects the branch only; it never excuses a field's matched values.
709
+ * When either field text starts with `*`, a local date must satisfy BOTH fields, so a
710
+ * stepped star such as `*` followed by `/2` still restricts the dates it matches. A bare
711
+ * `*` matches every value, which makes that field's match always true and degrades the
712
+ * AND to the other field, exactly like Vixie's `DOM_STAR`/`DOW_STAR` test. Only when
713
+ * neither field text starts with `*` does either field matching suffice.
714
+ * @param parsed - Parsed cron rule with canonical field text and matched values.
715
+ * @returns Whether one local date matches the rule.
716
+ */
717
+ function cronDateMatch(parsed) {
718
+ const months = new Set(parsed.months);
719
+ const daysOfMonth = new Set(parsed.daysOfMonth);
720
+ const daysOfWeek = new Set(parsed.daysOfWeek);
721
+ const dayOfMonthRestricted = !parsed.dayOfMonthStar;
722
+ const dayOfWeekRestricted = !parsed.dayOfWeekStar;
723
+ return (date) => {
724
+ /* v8 ignore next -- both callers pre-filter by month before matching a date. */
725
+ if (!months.has(date.month)) return false;
726
+ const dayOfMonth = daysOfMonth.has(date.day);
727
+ const dayOfWeek = daysOfWeek.has(date.dayOfWeek % 7);
728
+ if (dayOfMonthRestricted && dayOfWeekRestricted) return dayOfMonth || dayOfWeek;
729
+ return dayOfMonth && dayOfWeek;
730
+ };
731
+ }
732
+ /** Enumerate one parsed cron rule's local times of day in ascending wall-clock order. */
733
+ function cronTimes(parsed) {
734
+ const times = [];
735
+ for (const hour of parsed.hours) for (const minute of parsed.minutes) times.push(Temporal.PlainTime.from({
736
+ hour,
737
+ minute
738
+ }));
739
+ return times;
740
+ }
741
+ /**
742
+ * Find the first strictly future cron occurrence without leaving the four-digit UTC year range.
743
+ *
744
+ * The walk stops at {@link CRON_SEARCH_HORIZON_YEARS} past the floor date when that
745
+ * horizon precedes the four-digit ceiling: a rule with no match inside the horizon
746
+ * never matches, so its search resolves to exhaustion instead of scanning to the ceiling.
747
+ * @param parsed - Parsed cron rule.
748
+ * @param timeZone - Explicit zone interpreting its local date and time.
749
+ * @param floor - Instant every returned target must exceed.
750
+ * @returns The first matching target, or exhaustion as undefined.
751
+ */
752
+ function nextCronTarget(parsed, timeZone, floor) {
753
+ const times = cronTimes(parsed);
754
+ const matches = cronDateMatch(parsed);
755
+ const months = new Set(parsed.months);
756
+ const firstDate = localDate(floor, timeZone);
757
+ let date = firstDate;
758
+ const floorTime = Temporal.Instant.fromEpochMilliseconds(floor).toZonedDateTimeISO(timeZone).toPlainTime();
759
+ const ceiling = localDate(MAX_FOUR_DIGIT_YEAR_MS, "UTC").add({ days: 1 });
760
+ const horizon = date.add({ years: 400 });
761
+ const lastDate = Temporal.PlainDate.compare(horizon, ceiling) < 0 ? horizon : ceiling;
762
+ while (Temporal.PlainDate.compare(date, lastDate) <= 0) {
763
+ if (!months.has(date.month)) {
764
+ date = date.add({ months: 1 }).with({ day: 1 });
765
+ continue;
766
+ }
767
+ if (matches(date)) for (const time of times) {
768
+ if (date.equals(firstDate) && Temporal.PlainTime.compare(time, floorTime) < 0) continue;
769
+ const target = localInstant(date.toPlainDateTime(time), timeZone);
770
+ if (target === void 0) continue;
771
+ if (target > MAX_FOUR_DIGIT_YEAR_MS) return void 0;
772
+ if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > floor) return new Date(target).toISOString();
773
+ }
774
+ date = date.add({ days: 1 });
775
+ }
776
+ }
777
+ /**
778
+ * Find the latest matching cron occurrence at or before a decision without leaving the committed floor.
779
+ *
780
+ * The walk stops at {@link CRON_SEARCH_HORIZON_YEARS} before the decision date when that
781
+ * horizon follows the four-digit floor, which is the local date holding that floor instant
782
+ * in the rule's own zone: a rule with no match inside the horizon keeps the committed target
783
+ * instead of scanning back to the floor, and a candidate before the floor instant is skipped.
784
+ * @param parsed - Parsed cron rule.
785
+ * @param timeZone - Explicit zone interpreting its local date and time.
786
+ * @param decision - Wall-clock decision time in epoch milliseconds.
787
+ * @param savedTarget - Committed target that stays due even when current zone data resolves past it.
788
+ * @returns The latest due occurrence, or the committed target when no local occurrence is representable.
789
+ */
790
+ function latestCronOccurrence(parsed, timeZone, decision, savedTarget) {
791
+ const times = cronTimes(parsed);
792
+ const matches = cronDateMatch(parsed);
793
+ const months = new Set(parsed.months);
794
+ let date = localDate(decision, "UTC").add({ days: 1 });
795
+ const floorDate = localDate(MIN_FOUR_DIGIT_YEAR_MS, timeZone);
796
+ const horizon = date.subtract({ years: 400 });
797
+ const firstDate = Temporal.PlainDate.compare(horizon, floorDate) > 0 ? horizon : floorDate;
798
+ while (Temporal.PlainDate.compare(date, firstDate) >= 0) {
799
+ if (!months.has(date.month)) {
800
+ date = date.with({ day: 1 }).subtract({ days: 1 });
801
+ continue;
802
+ }
803
+ if (matches(date)) {
804
+ const earliest = times.reduce((found, time) => found ?? localInstant(date.toPlainDateTime(time), timeZone), void 0);
805
+ if (earliest !== void 0 && earliest > decision) {
806
+ date = date.subtract({ days: 1 });
807
+ continue;
808
+ }
809
+ for (const time of [...times].reverse()) {
810
+ const candidate = localInstant(date.toPlainDateTime(time), timeZone);
811
+ if (candidate === void 0 || candidate > decision || candidate < MIN_FOUR_DIGIT_YEAR_MS) continue;
812
+ return Math.max(candidate, savedTarget);
813
+ }
814
+ }
815
+ date = date.subtract({ days: 1 });
816
+ }
817
+ return savedTarget;
818
+ }
819
+ /**
820
+ * Resolve a cron decision near the decision's local date, not across its missed history.
821
+ * @param record - Cron rule with a committed earliest unaccepted UTC target.
822
+ * @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.
823
+ * @returns Latest actual due occurrence and the first future occurrence.
824
+ */
825
+ function resolveCronOccurrence(record, acceptedAt) {
826
+ const decision = acceptedDecision("cron", acceptedAt);
827
+ const savedTarget = Date.parse(record.scheduledAt);
828
+ if (decision < savedTarget) throw new ScheduleLogError("cron dispatch cannot precede the active scheduledAt");
829
+ const parsed = parseCronExpression(record.expression);
830
+ const occurrence = latestCronOccurrence(parsed, record.timeZone, decision, savedTarget);
831
+ const occurrenceAt = new Date(occurrence).toISOString();
832
+ const nextScheduledAt = nextCronTarget(parsed, record.timeZone, decision);
833
+ return Object.freeze(nextScheduledAt === void 0 ? { occurrenceAt } : {
834
+ occurrenceAt,
835
+ nextScheduledAt
836
+ });
837
+ }
838
+ /**
839
+ * Identify recurring Host records explicitly, excluding both one-shot variants.
840
+ * @param record - Current Host schedule record.
841
+ * @returns Whether the record uses a recurring rule.
842
+ */
843
+ function isRecurringScheduleRecord(record) {
844
+ return record.kind === "every" || record.kind === "daily" || record.kind === "weekly" || record.kind === "cron";
845
+ }
846
+ /**
847
+ * Resolve one due recurring Host record with its rule-specific calendar or interval arithmetic.
848
+ * @param record - Due recurring rule.
849
+ * @param acceptedAt - Explicit decision time in epoch milliseconds.
850
+ * @returns Latest due occurrence and optional future target.
851
+ */
852
+ function resolveRecurringOccurrence(record, acceptedAt) {
853
+ switch (record.kind) {
854
+ case "every": return resolveEveryOccurrence(record, acceptedAt);
855
+ case "daily": return resolveDailyOccurrence(record, acceptedAt);
856
+ case "weekly": return resolveWeeklyOccurrence(record, acceptedAt);
857
+ case "cron": return resolveCronOccurrence(record, acceptedAt);
858
+ }
859
+ }
860
+ /** Apply one decoded dispatch to its exact active record. */
861
+ function dispatchedRecord(record, change) {
862
+ const hasAcceptedAt = "acceptedAt" in change;
863
+ if (record.kind !== "every") {
864
+ if (hasAcceptedAt) throw new ScheduleLogError("one-shot dispatch must not contain acceptedAt");
865
+ return;
866
+ }
867
+ if (!hasAcceptedAt) throw new ScheduleLogError("every dispatch must contain acceptedAt");
868
+ const occurrence = resolveEveryOccurrence(record, Date.parse(change.acceptedAt));
869
+ return occurrence.nextScheduledAt === void 0 ? void 0 : Object.freeze({
870
+ ...record,
871
+ scheduledAt: occurrence.nextScheduledAt
872
+ });
873
+ }
874
+ /**
875
+ * Apply already-decoded Schedule changes to one complete fold value.
876
+ *
877
+ * The transition authority for full-log replay. One mutable Map/Set pair spans
878
+ * the whole batch; the returned arrays are materialized and frozen once.
879
+ * @param folded - complete active records and used-id history before the changes.
880
+ * @param changes - strictly decoded durable mutations in log order.
881
+ * @returns the complete fold value after every mutation.
882
+ */
883
+ function applyScheduleChanges(folded, changes) {
884
+ const active = new Map(folded.active.map((record) => [record.id, record]));
885
+ const seen = new Set(folded.seenIds);
886
+ for (const change of changes) switch (change.operation) {
887
+ case "create":
888
+ if (seen.has(change.schedule.id)) throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`);
889
+ seen.add(change.schedule.id);
890
+ active.set(change.schedule.id, change.schedule);
891
+ break;
892
+ case "delete":
893
+ if (!active.delete(change.id)) throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`);
894
+ break;
895
+ case "dispatch": {
896
+ const record = active.get(change.id);
897
+ if (record === void 0) throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`);
898
+ const next = dispatchedRecord(record, change);
899
+ if (next === void 0) active.delete(change.id);
900
+ else active.set(change.id, next);
901
+ break;
902
+ }
903
+ /* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
904
+ default: throw new ScheduleLogError(`unknown decoded schedule change ${String(change)}`);
905
+ }
906
+ return Object.freeze({
907
+ active: Object.freeze([...active.values()]),
908
+ seenIds: Object.freeze([...seen])
909
+ });
910
+ }
911
+ /**
912
+ * Fold the package-owned stream after the durable fork seed boundary.
913
+ * @param events - Complete ordered session log or candidate-extended log.
914
+ * @param inheritedEventCount - Inherited prefix length excluded from child ownership.
915
+ * @returns Active records and all previously used ids.
916
+ */
917
+ function foldScheduleEvents(events, inheritedEventCount = SessionLogOffset(0)) {
918
+ if (!Number.isSafeInteger(inheritedEventCount) || inheritedEventCount < 0 || inheritedEventCount > events.length) throw new ScheduleLogError("schedule inheritedEventCount must be within the supplied event log");
919
+ const initial = Object.freeze({
920
+ active: Object.freeze([]),
921
+ seenIds: Object.freeze([])
922
+ });
923
+ const changes = function* () {
924
+ for (const event of events.slice(inheritedEventCount)) if (event.type === "schedule/change") yield decodeScheduleChange(event.data);
925
+ };
926
+ return applyScheduleChanges(initial, changes());
927
+ }
928
+ /**
929
+ * Validate a model after rule and compute its durable target.
930
+ * @param id - Already allocated task id.
931
+ * @param prompt - Reminder content supplied at creation.
932
+ * @param afterSeconds - Requested positive delay.
933
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
934
+ * @param title - Required task name supplied at creation.
935
+ * @returns Frozen durable after record.
936
+ */
937
+ function createAfterScheduleRecord(id, prompt, afterSeconds, now, title) {
938
+ const normalizedPrompt = prompt.trim();
939
+ if (normalizedPrompt.length === 0) throw new ScheduleInputError("invalid_prompt", "prompt must be non-empty after trimming.");
940
+ if (!Number.isSafeInteger(afterSeconds) || afterSeconds <= 0) throw new ScheduleInputError("invalid_rule", "after_seconds must be a positive safe integer.");
941
+ const target = now + afterSeconds * 1e3;
942
+ return Object.freeze({
943
+ id,
944
+ kind: "after",
945
+ title: scheduleTitle(title),
946
+ prompt: normalizedPrompt,
947
+ afterSeconds,
948
+ scheduledAt: futureInstant(target, now)
949
+ });
950
+ }
951
+ /**
952
+ * Validate an absolute selector and compute its sole durable UTC target.
953
+ * @param id - Already allocated task id.
954
+ * @param prompt - Reminder content supplied at creation.
955
+ * @param at - Explicit-offset instant or structured local calendar value.
956
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
957
+ * @param title - Required task name supplied at creation.
958
+ * @returns Frozen durable absolute one-shot record.
959
+ */
960
+ function createAtScheduleRecord(id, prompt, at, now, title) {
961
+ const normalizedPrompt = prompt.trim();
962
+ if (normalizedPrompt.length === 0) throw new ScheduleInputError("invalid_prompt", "prompt must be non-empty after trimming.");
963
+ return Object.freeze({
964
+ id,
965
+ kind: "at",
966
+ title: scheduleTitle(title),
967
+ prompt: normalizedPrompt,
968
+ scheduledAt: futureInstant(parseAtInput(at), now)
969
+ });
970
+ }
971
+ /**
972
+ * Parse an absolute selector without requiring it to be future.
973
+ * @param at - Explicit-offset instant or strict local calendar input.
974
+ * @returns Resolved epoch milliseconds; malformed input throws ScheduleInputError.
975
+ */
976
+ function parseAtInput(at) {
977
+ let target;
978
+ if (typeof at === "string") target = parseOffsetInstant(at);
979
+ else if (isRecord(at)) {
980
+ if (!hasExactKeys(at, [
981
+ "date",
982
+ "time",
983
+ "time_zone"
984
+ ])) throw new ScheduleInputError("invalid_rule", "Local at must contain exactly date, time, and time_zone.");
985
+ if (typeof at["date"] !== "string" || typeof at["time"] !== "string") throw new ScheduleInputError("invalid_rule", "Local at date and time must be strings.");
986
+ const rawTimeZone = at["time_zone"];
987
+ if (typeof rawTimeZone !== "string") throw new ScheduleInputError("invalid_time_zone", "time_zone must be a string.");
988
+ target = resolveLocalInstant(parseLocalAt({
989
+ date: at["date"],
990
+ time: at["time"],
991
+ time_zone: rawTimeZone
992
+ }), canonicalizeTimeZone(rawTimeZone));
993
+ } else throw new ScheduleInputError("invalid_rule", "at must be an explicit-offset string or local calendar object.");
994
+ return target;
995
+ }
996
+ /**
997
+ * Validate a fixed-rate selector and compute the first target of a new interval anchor.
998
+ * @param id - Already allocated task id.
999
+ * @param prompt - Reminder content supplied at creation.
1000
+ * @param everySeconds - Requested fixed safe-integer interval.
1001
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1002
+ * @param title - Required task name supplied at creation.
1003
+ * @returns Frozen durable fixed-rate record.
1004
+ */
1005
+ function createEveryScheduleRecord(id, prompt, everySeconds, now, title) {
1006
+ const normalizedPrompt = prompt.trim();
1007
+ if (normalizedPrompt.length === 0) throw new ScheduleInputError("invalid_prompt", "prompt must be non-empty after trimming.");
1008
+ if (!Number.isSafeInteger(everySeconds)) throw new ScheduleInputError("invalid_rule", "every_seconds must be a safe integer.");
1009
+ if (everySeconds < 60) throw new ScheduleInputError("frequency_too_high", `every_seconds must be at least 60.`);
1010
+ const target = now + everySeconds * 1e3;
1011
+ return Object.freeze({
1012
+ id,
1013
+ kind: "every",
1014
+ title: scheduleTitle(title),
1015
+ prompt: normalizedPrompt,
1016
+ everySeconds,
1017
+ scheduledAt: futureInstant(target, now)
1018
+ });
1019
+ }
1020
+ /**
1021
+ * Create a daily wall-clock rule with a strictly future committed UTC target.
1022
+ * @param id - Already allocated task identity.
1023
+ * @param prompt - Reminder content supplied at creation.
1024
+ * @param daily - Strict local time and explicit IANA zone.
1025
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1026
+ * @param title - Required task name supplied at creation.
1027
+ * @returns Frozen daily record; absent future dates throw time_out_of_range.
1028
+ */
1029
+ function createDailyScheduleRecord(id, prompt, daily, now, title) {
1030
+ const normalizedPrompt = prompt.trim();
1031
+ if (normalizedPrompt.length === 0) throw new ScheduleInputError("invalid_prompt", "prompt must be non-empty after trimming.");
1032
+ const { time, timeZone } = parseDailyInput(daily);
1033
+ if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) throw new ScheduleInputError("time_out_of_range", "Daily creation time must be a representable four-digit-year UTC instant.");
1034
+ const scheduledAt = nextDailyTarget(dailyTime(time), timeZone, now);
1035
+ if (scheduledAt === void 0) throw new ScheduleInputError("time_out_of_range", "No future daily occurrence is representable as a four-digit-year UTC instant.");
1036
+ return Object.freeze({
1037
+ id,
1038
+ kind: "daily",
1039
+ title: scheduleTitle(title),
1040
+ prompt: normalizedPrompt,
1041
+ time,
1042
+ timeZone,
1043
+ scheduledAt
1044
+ });
1045
+ }
1046
+ /**
1047
+ * Normalize a daily selector without calculating a new committed target.
1048
+ * @param daily - Strict local time and explicit IANA zone.
1049
+ * @returns Normalized time and canonical zone; malformed input throws ScheduleInputError.
1050
+ */
1051
+ function parseDailyInput(daily) {
1052
+ if (!isRecord(daily) || !hasExactKeys(daily, ["time", "time_zone"]) || typeof daily["time"] !== "string") throw new ScheduleInputError("invalid_rule", "daily must contain exactly time and time_zone, with time HH:mm:ss and optional 1-3 fractional digits.");
1053
+ if (typeof daily["time_zone"] !== "string") throw new ScheduleInputError("invalid_time_zone", "time_zone must be a string.");
1054
+ return {
1055
+ time: dailyTime(daily["time"]).toString({ fractionalSecondDigits: 3 }),
1056
+ timeZone: canonicalizeTimeZone(daily["time_zone"])
1057
+ };
1058
+ }
1059
+ /**
1060
+ * Create a weekly wall-clock rule with a strictly future committed UTC target.
1061
+ * @param id - Already allocated task identity.
1062
+ * @param prompt - Reminder content supplied at creation.
1063
+ * @param weekly - Strict local time, explicit IANA zone, and explicit ISO weekday set.
1064
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1065
+ * @param title - Required task name supplied at creation.
1066
+ * @returns Frozen weekly record; absent future weekdays throw time_out_of_range.
1067
+ */
1068
+ function createWeeklyScheduleRecord(id, prompt, weekly, now, title) {
1069
+ const normalizedPrompt = prompt.trim();
1070
+ if (normalizedPrompt.length === 0) throw new ScheduleInputError("invalid_prompt", "prompt must be non-empty after trimming.");
1071
+ const { time, timeZone, weekdays } = parseWeeklyInput(weekly);
1072
+ if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) throw new ScheduleInputError("time_out_of_range", "Weekly creation time must be a representable four-digit-year UTC instant.");
1073
+ const scheduledAt = nextWeeklyTarget(weeklyTime(time), timeZone, weekdays, now);
1074
+ if (scheduledAt === void 0) throw new ScheduleInputError("time_out_of_range", "No future weekly occurrence is representable as a four-digit-year UTC instant.");
1075
+ return Object.freeze({
1076
+ id,
1077
+ kind: "weekly",
1078
+ title: scheduleTitle(title),
1079
+ prompt: normalizedPrompt,
1080
+ time,
1081
+ timeZone,
1082
+ weekdays,
1083
+ scheduledAt
1084
+ });
1085
+ }
1086
+ /**
1087
+ * Create a cron wall-clock rule with a strictly future committed UTC target.
1088
+ * @param id - Already allocated task identity.
1089
+ * @param prompt - Reminder content supplied at creation.
1090
+ * @param cron - Strict five-field expression and explicit IANA zone.
1091
+ * @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.
1092
+ * @param title - Required task name supplied at creation.
1093
+ * @returns Frozen cron record; absent future occurrences throw time_out_of_range.
1094
+ */
1095
+ function createCronScheduleRecord(id, prompt, cron, now, title) {
1096
+ const normalizedPrompt = prompt.trim();
1097
+ if (normalizedPrompt.length === 0) throw new ScheduleInputError("invalid_prompt", "prompt must be non-empty after trimming.");
1098
+ const { expression, timeZone } = parseCronInput(cron);
1099
+ if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) throw new ScheduleInputError("time_out_of_range", "Cron creation time must be a representable four-digit-year UTC instant.");
1100
+ const scheduledAt = nextCronTarget(parseCronExpression(expression), timeZone, now);
1101
+ if (scheduledAt === void 0) throw new ScheduleInputError("time_out_of_range", "No future cron occurrence is representable as a four-digit-year UTC instant.");
1102
+ return Object.freeze({
1103
+ id,
1104
+ kind: "cron",
1105
+ title: scheduleTitle(title),
1106
+ prompt: normalizedPrompt,
1107
+ expression,
1108
+ timeZone,
1109
+ scheduledAt
1110
+ });
1111
+ }
1112
+ /**
1113
+ * Normalize a weekly selector without calculating a new committed target.
1114
+ * @param weekly - Strict local time, explicit IANA zone, and explicit ISO weekday set.
1115
+ * @returns Normalized time, canonical zone, and unique ascending weekdays; malformed input throws ScheduleInputError.
1116
+ */
1117
+ function parseWeeklyInput(weekly) {
1118
+ if (!isRecord(weekly) || !hasExactKeys(weekly, [
1119
+ "time",
1120
+ "time_zone",
1121
+ "weekdays"
1122
+ ]) || typeof weekly["time"] !== "string") 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.");
1123
+ if (typeof weekly["time_zone"] !== "string") throw new ScheduleInputError("invalid_time_zone", "time_zone must be a string.");
1124
+ return {
1125
+ time: weeklyTime(weekly["time"]).toString({ fractionalSecondDigits: 3 }),
1126
+ timeZone: canonicalizeTimeZone(weekly["time_zone"]),
1127
+ weekdays: normalizeWeekdays(weekly["weekdays"])
1128
+ };
1129
+ }
1130
+ const CRON_FIELDS = [
1131
+ {
1132
+ name: "minute",
1133
+ min: 0,
1134
+ max: 59,
1135
+ canonicalMax: 59
1136
+ },
1137
+ {
1138
+ name: "hour",
1139
+ min: 0,
1140
+ max: 23,
1141
+ canonicalMax: 23
1142
+ },
1143
+ {
1144
+ name: "day-of-month",
1145
+ min: 1,
1146
+ max: 31,
1147
+ canonicalMax: 31
1148
+ },
1149
+ {
1150
+ name: "month",
1151
+ min: 1,
1152
+ max: 12,
1153
+ canonicalMax: 12
1154
+ },
1155
+ {
1156
+ name: "day-of-week",
1157
+ min: 0,
1158
+ max: 7,
1159
+ canonicalMax: 6
1160
+ }
1161
+ ];
1162
+ /** Build the stable diagnostic for one malformed cron field element. */
1163
+ function invalidCronField(spec, element) {
1164
+ return new ScheduleInputError("invalid_rule", `cron.expression ${spec.name} field element ${JSON.stringify(element)} must be *, a value, a-b, */n, a-b/n, or a comma-separated list of those.`);
1165
+ }
1166
+ /** Read one in-range cron field value. */
1167
+ function cronFieldValue(text, spec) {
1168
+ const value = Number(text);
1169
+ if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) throw new ScheduleInputError("invalid_rule", `cron.expression ${spec.name} field value ${text} is outside ${spec.min}-${spec.max}.`);
1170
+ return value;
1171
+ }
1172
+ /** Read one positive cron field step. */
1173
+ function cronFieldStep(text, spec) {
1174
+ const step = Number(text);
1175
+ if (!Number.isSafeInteger(step) || step < 1) throw new ScheduleInputError("invalid_rule", `cron.expression ${spec.name} field step must be a positive integer.`);
1176
+ return step;
1177
+ }
1178
+ /** Expand one comma-separated cron field into its matched value set. */
1179
+ function cronFieldValues(raw, spec) {
1180
+ const matched = /* @__PURE__ */ new Set();
1181
+ for (const element of raw.split(",")) {
1182
+ if (element.length === 0) throw new ScheduleInputError("invalid_rule", `cron.expression ${spec.name} field must not contain an empty list element.`);
1183
+ if (element === "*") {
1184
+ for (let value = spec.min; value <= spec.max; value += 1) matched.add(value);
1185
+ continue;
1186
+ }
1187
+ if (element.startsWith("*")) {
1188
+ const groups = /^\*\/(?<step>\d+)$/.exec(element)?.groups;
1189
+ if (groups === void 0) throw invalidCronField(spec, element);
1190
+ const stepText = groups["step"];
1191
+ /* v8 ignore next -- a successful fixed regex always provides the step group. */
1192
+ if (stepText === void 0) throw invalidCronField(spec, element);
1193
+ const step = cronFieldStep(stepText, spec);
1194
+ for (let value = spec.min; value <= spec.max; value += step) matched.add(value);
1195
+ continue;
1196
+ }
1197
+ const groups = /^(?<start>\d+)(?:-(?<end>\d+))?(?:\/(?<step>\d+))?$/.exec(element)?.groups;
1198
+ if (groups === void 0) throw invalidCronField(spec, element);
1199
+ const startText = groups["start"];
1200
+ /* v8 ignore next -- a successful fixed regex always provides the start group. */
1201
+ if (startText === void 0) throw invalidCronField(spec, element);
1202
+ const start = cronFieldValue(startText, spec);
1203
+ const end = groups["end"];
1204
+ const stepText = groups["step"];
1205
+ if (end === void 0) {
1206
+ if (stepText !== void 0) throw invalidCronField(spec, element);
1207
+ matched.add(start);
1208
+ continue;
1209
+ }
1210
+ const last = cronFieldValue(end, spec);
1211
+ if (start > last) throw new ScheduleInputError("invalid_rule", `cron.expression ${spec.name} field range ${start}-${last} is inverted.`);
1212
+ const step = stepText === void 0 ? 1 : cronFieldStep(stepText, spec);
1213
+ for (let value = start; value <= last; value += step) matched.add(value);
1214
+ }
1215
+ const values = /* @__PURE__ */ new Set();
1216
+ for (const value of matched) values.add(spec.canonicalMax !== spec.max && value === spec.max ? spec.min : value);
1217
+ return [...values].sort((left, right) => left - right);
1218
+ }
1219
+ /**
1220
+ * Read one value the encoder already proved present.
1221
+ * @param values - matched value set the parser always fills.
1222
+ * @param index - index the caller derived from that set.
1223
+ * @returns the value at that index.
1224
+ */
1225
+ function cronFieldValueAt(values, index) {
1226
+ const value = values[index];
1227
+ /* v8 ignore next -- a parsed cron field always matches at least one value. */
1228
+ if (value === void 0) throw new ScheduleLogError("cron field encoding requires a non-empty value set");
1229
+ return value;
1230
+ }
1231
+ /** Encode one matched value set as the shortest equivalent comma-separated cron field.
1232
+ * @param values - Matched values in ascending order.
1233
+ * @returns Field text that re-parses to exactly `values`, never spelled with a leading `*`.
1234
+ */
1235
+ function encodeCronField(values) {
1236
+ const first = cronFieldValueAt(values, 0);
1237
+ if (values.length === 1) return String(first);
1238
+ const last = cronFieldValueAt(values, values.length - 1);
1239
+ const step = cronFieldValueAt(values, 1) - first;
1240
+ let uniform = true;
1241
+ for (let index = 2; index < values.length; index += 1) if (cronFieldValueAt(values, index) - cronFieldValueAt(values, index - 1) !== step) {
1242
+ uniform = false;
1243
+ break;
1244
+ }
1245
+ if (uniform) {
1246
+ if (step === 1) return `${first}-${last}`;
1247
+ return `${first}-${last}/${step}`;
1248
+ }
1249
+ const parts = [];
1250
+ let runStart = first;
1251
+ for (let index = 1; index < values.length; index += 1) {
1252
+ const current = cronFieldValueAt(values, index);
1253
+ const previous = cronFieldValueAt(values, index - 1);
1254
+ if (current === previous + 1) continue;
1255
+ parts.push(runStart === previous ? String(runStart) : `${runStart}-${previous}`);
1256
+ runStart = current;
1257
+ }
1258
+ const final = cronFieldValueAt(values, values.length - 1);
1259
+ parts.push(runStart === final ? String(runStart) : `${runStart}-${final}`);
1260
+ return parts.join(",");
1261
+ }
1262
+ /**
1263
+ * One uniform walk from a field's minimum, folded exactly as a parsed field folds.
1264
+ * @param spec - Field range and canonical maximum.
1265
+ * @param step - Positive step of the walk.
1266
+ * @returns Ascending values `step` produces from the minimum.
1267
+ */
1268
+ function starWalk(spec, step) {
1269
+ const walked = /* @__PURE__ */ new Set();
1270
+ for (let value = spec.min; value <= spec.max; value += step) walked.add(spec.canonicalMax !== spec.max && value === spec.max ? spec.min : value);
1271
+ return [...walked].sort((left, right) => left - right);
1272
+ }
1273
+ /**
1274
+ * Canonical spelling of a field whose text started with `*`.
1275
+ *
1276
+ * Only a `*`-prefixed spelling keeps the star flag, and only the bare star or a star-step
1277
+ * (a `*` followed by `/n`) admits a leading `*`, so the encoding is the bare star for every
1278
+ * value, otherwise the widest star-step walk plus any remaining values as a list.
1279
+ * Re-parsing therefore reproduces both the star flag and the matched set, which is what
1280
+ * keeps day-of-month/day-of-week AND/OR selection stable across a stored canonical
1281
+ * expression.
1282
+ * @param values - Matched values in ascending order.
1283
+ * @param spec - Field range and canonical maximum.
1284
+ * @returns Canonical field text that starts with `*`.
1285
+ */
1286
+ function encodeStarCronField(values, spec) {
1287
+ if (values.length === spec.canonicalMax - spec.min + 1) return "*";
1288
+ const present = new Set(values);
1289
+ let bestStep;
1290
+ let bestWalk = [];
1291
+ for (let step = 1; step <= spec.max - spec.min + 1; step += 1) {
1292
+ const walk = starWalk(spec, step);
1293
+ if (walk.length > bestWalk.length && walk.every((value) => present.has(value))) {
1294
+ bestStep = step;
1295
+ bestWalk = walk;
1296
+ }
1297
+ }
1298
+ /* v8 ignore next -- the widest step yields the single minimum, which every `*`-led field matches. */
1299
+ if (bestStep === void 0) throw new ScheduleInputError("invalid_rule", `${spec.name} cannot keep a leading \`*\`.`);
1300
+ const walked = new Set(bestWalk);
1301
+ const remaining = values.filter((value) => !walked.has(value));
1302
+ const walkText = `*/${bestStep}`;
1303
+ return remaining.length === 0 ? walkText : `${walkText},${encodeCronField(remaining)}`;
1304
+ }
1305
+ /**
1306
+ * Parse one cron field into its matched values, canonical spelling, and star flag.
1307
+ *
1308
+ * The star flag tests the field text's first character, which is how Vixie sets
1309
+ * `DOM_STAR`/`DOW_STAR`: a stepped star (`*` followed by `/2`) is a star although
1310
+ * it matches half the range, while an explicit full range such as `1-31` is
1311
+ * restricted. Canonicalization preserves the flag: a `*`-led field keeps a `*`-led
1312
+ * spelling, and a field that did not start with `*` is never spelled as a star-step.
1313
+ */
1314
+ function parseCronField(raw, spec) {
1315
+ const values = cronFieldValues(raw, spec);
1316
+ const star = raw.startsWith("*");
1317
+ return Object.freeze({
1318
+ values: Object.freeze(values),
1319
+ canonical: star ? encodeStarCronField(values, spec) : encodeCronField(values),
1320
+ star
1321
+ });
1322
+ }
1323
+ /** Parse and canonicalize one strict five-field cron expression. */
1324
+ function parseCronExpression(expression) {
1325
+ if (typeof expression !== "string" || expression.length === 0 || expression.trim() !== expression) throw new ScheduleInputError("invalid_rule", "cron.expression must be a non-empty trimmed string.");
1326
+ const fields = expression.split(/\s+/);
1327
+ if (fields.length !== 5) throw new ScheduleInputError("invalid_rule", "cron.expression must contain exactly five whitespace-separated fields: minute hour day-of-month month day-of-week.");
1328
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields.map((field, index) => {
1329
+ const fieldSpec = CRON_FIELDS[index];
1330
+ /* v8 ignore next -- the five-field split bounds this index. */
1331
+ if (fieldSpec === void 0) throw new ScheduleLogError("cron field specification is missing");
1332
+ return parseCronField(field, fieldSpec);
1333
+ });
1334
+ return Object.freeze({
1335
+ expression: [
1336
+ minute,
1337
+ hour,
1338
+ dayOfMonth,
1339
+ month,
1340
+ dayOfWeek
1341
+ ].map((field) => field.canonical).join(" "),
1342
+ minutes: minute.values,
1343
+ hours: hour.values,
1344
+ daysOfMonth: dayOfMonth.values,
1345
+ months: month.values,
1346
+ daysOfWeek: dayOfWeek.values,
1347
+ dayOfMonthStar: dayOfMonth.star,
1348
+ dayOfWeekStar: dayOfWeek.star
1349
+ });
1350
+ }
1351
+ /**
1352
+ * Canonicalize one strict five-field cron expression.
1353
+ * @param expression - Candidate `minute hour day-of-month month day-of-week` expression.
1354
+ * @returns The canonical expression; malformed or unsupported input throws ScheduleInputError.
1355
+ */
1356
+ function canonicalizeCronExpression(expression) {
1357
+ return parseCronExpression(expression).expression;
1358
+ }
1359
+ /**
1360
+ * Normalize a cron selector without calculating a new committed target.
1361
+ * @param cron - Strict five-field expression and explicit IANA zone.
1362
+ * @returns The canonical expression and canonical zone; malformed input throws ScheduleInputError.
1363
+ */
1364
+ function parseCronInput(cron) {
1365
+ if (!isRecord(cron) || !hasExactKeys(cron, ["expression", "time_zone"]) || typeof cron["expression"] !== "string") throw new ScheduleInputError("invalid_rule", "cron must contain exactly expression and time_zone, with a five-field cron expression.");
1366
+ if (typeof cron["time_zone"] !== "string") throw new ScheduleInputError("invalid_time_zone", "time_zone must be a string.");
1367
+ return {
1368
+ expression: parseCronExpression(cron["expression"]).expression,
1369
+ timeZone: canonicalizeTimeZone(cron["time_zone"])
1370
+ };
1371
+ }
1372
+ /**
1373
+ * Derive one execution-local management view.
1374
+ * @param record - Active durable record.
1375
+ * @param now - Wall-clock sample used for its timing state.
1376
+ * @returns Complete Host delivery view.
1377
+ */
1378
+ function scheduleView(record, now) {
1379
+ return Object.freeze({
1380
+ ...record,
1381
+ state: now >= Date.parse(record.scheduledAt) ? "overdue" : "scheduled",
1382
+ deliveryMode: "host"
1383
+ });
1384
+ }
1385
+ /** Fixed model-facing origin line shared by one-shot and recurring reminder delivery. */
1386
+ const SCHEDULED_MESSAGE_FRAMING = "This is a scheduled message from the user";
1387
+ /**
1388
+ * Render the fixed model framing for a due reminder.
1389
+ * @param record - Due active record.
1390
+ * @returns Stable model-visible text with JSON-escaped dynamic fields.
1391
+ */
1392
+ function renderReminderFraming(record) {
1393
+ return [
1394
+ "[SCHEDULE REMINDER]",
1395
+ SCHEDULED_MESSAGE_FRAMING,
1396
+ `schedule_id_json: ${JSON.stringify(record.id)}`,
1397
+ `occurrence_at: ${record.scheduledAt}`,
1398
+ `reminder_prompt_json: ${JSON.stringify(record.prompt)}`
1399
+ ].join("\n");
1400
+ }
1401
+ /**
1402
+ * Render one recurring reminder batch in the supplied order.
1403
+ * @param reminders - Complete admitted batch with one latest occurrence per record.
1404
+ * @returns Stable model-visible text whose dynamic payload is canonical JSON.
1405
+ */
1406
+ function renderRecurringReminderBatchFraming(reminders) {
1407
+ const payload = reminders.map(({ record, occurrenceAt }) => ({
1408
+ schedule_id: record.id,
1409
+ occurrence_at: occurrenceAt,
1410
+ reminder_prompt: record.prompt
1411
+ }));
1412
+ return [
1413
+ "[SCHEDULE REMINDER BATCH]",
1414
+ SCHEDULED_MESSAGE_FRAMING,
1415
+ `reminders_json: ${JSON.stringify(payload)}`
1416
+ ].join("\n");
1417
+ }
1418
+ //#endregion
1419
+ //#region lib/types/delivery-history.js
1420
+ /** Milliseconds in one day, the unit the retention window is stated in. */
1421
+ const DAY_MS = 864e5;
1422
+ /**
1423
+ * Read retained history; missing history exposes only the actual legacy receipt.
1424
+ * @param task - Immutable stored task.
1425
+ * @returns Oldest-first retained deliveries without changing storage.
1426
+ */
1427
+ function deliveryHistoryOf(task) {
1428
+ return task.deliveryHistory ?? {
1429
+ records: task.lastDelivery === void 0 ? [] : [task.lastDelivery],
1430
+ earlierRecordsUnavailable: true
1431
+ };
1432
+ }
1433
+ /**
1434
+ * Prepare one real inbox acknowledgment for the same task write as its status and target.
1435
+ * Prunes to the configured window and record cap here, on the only write path: the
1436
+ * domain publishes one whole-unit document per write, so an unbounded array would
1437
+ * grow that document with every acknowledgment. The read path states no bound of
1438
+ * its own, because a whole-unit document over the schema's limits refuses to open.
1439
+ * @param task - Task supplying the immutable sent prompt and retained history.
1440
+ * @param receipt - Acknowledgment obtained after successful Session flush.
1441
+ * @param bounds - Configured retention window and record cap for the appended history.
1442
+ * @returns Receipt and retained history; the caller publishes them only after task persistence.
1443
+ */
1444
+ function appendDelivery(task, receipt, bounds) {
1445
+ const history = deliveryHistoryOf(task);
1446
+ const appended = [...history.records, {
1447
+ ...receipt,
1448
+ prompt: task.record.prompt
1449
+ }];
1450
+ const floor = Date.parse(receipt.deliveredAt) - bounds.days * DAY_MS;
1451
+ const retained = appended.filter((record) => Date.parse(record.deliveredAt) >= floor).slice(-bounds.records);
1452
+ return {
1453
+ lastDelivery: receipt,
1454
+ deliveryHistory: {
1455
+ records: retained,
1456
+ earlierRecordsUnavailable: history.earlierRecordsUnavailable || retained.length !== appended.length,
1457
+ earlierRecordsPruned: history.earlierRecordsPruned === true || retained.length !== appended.length
1458
+ }
1459
+ };
1460
+ }
1461
+ /**
1462
+ * Read one newest-first page in append order, independent of wall-clock ordering.
1463
+ * @param task - Task already checked against the requested Session binding.
1464
+ * @param request - Validated explicit page size and optional exclusive message cursor.
1465
+ * @param retention - Current configured limits shared with the delivery writer.
1466
+ * @returns Copied delivery records or a cursor-not-found result.
1467
+ */
1468
+ function deliveryHistoryPage(task, request, retention) {
1469
+ const history = deliveryHistoryOf(task);
1470
+ const end = request.before === void 0 ? history.records.length : history.records.findIndex((record) => record.messageId === request.before);
1471
+ if (end === -1) return {
1472
+ id: request.id,
1473
+ code: "delivery_cursor_not_found"
1474
+ };
1475
+ const start = Math.max(0, end - request.limit);
1476
+ const records = history.records.slice(start, end).reverse().map(({ prompt, ...receipt }) => ({
1477
+ ...receipt,
1478
+ ...prompt === void 0 ? {} : { prompt }
1479
+ }));
1480
+ const oldest = records.at(-1);
1481
+ return {
1482
+ id: request.id,
1483
+ records,
1484
+ earlierRecordsUnavailable: history.earlierRecordsUnavailable,
1485
+ earlierRecordsPruned: history.earlierRecordsPruned === true,
1486
+ retention: { ...retention },
1487
+ ...oldest !== void 0 && start > 0 ? { nextBefore: oldest.messageId } : {}
1488
+ };
1489
+ }
1490
+ //#endregion
1491
+ //#region lib/types/runtime.js
1492
+ /** Host timer over stored tasks; Session activation is a delivery operation. */
1493
+ /** Largest delay Node timers represent without clamping. */
1494
+ const MAX_TIMER_DELAY_MS = 2147483647;
1495
+ /** Owns at most one timer across recomputations; delivery and management share the serialized operation. */
1496
+ var ScheduleRuntime = class {
1497
+ ctx;
1498
+ tasks;
1499
+ transact;
1500
+ commit;
1501
+ retention;
1502
+ timer;
1503
+ running;
1504
+ stopping = false;
1505
+ requested = false;
1506
+ /**
1507
+ * @param ctx - Host services used to resume and enqueue.
1508
+ * @param tasks - Current durable tasks.
1509
+ * @param transact - Serialize delivery against management writes.
1510
+ * @param commit - Persist task status, target, receipt, and history together after durable inbox delivery.
1511
+ */
1512
+ constructor(ctx, tasks, transact, commit, retention) {
1513
+ this.ctx = ctx;
1514
+ this.tasks = tasks;
1515
+ this.transact = transact;
1516
+ this.commit = commit;
1517
+ this.retention = retention;
1518
+ }
1519
+ /**
1520
+ * Recompute the nearest obligation after startup or a durable change.
1521
+ * Dispatch failures are logged; refused admission does not retry automatically.
1522
+ */
1523
+ requestDrive() {
1524
+ if (this.stopping) return;
1525
+ this.requested = true;
1526
+ this.clearTimer();
1527
+ if (this.running !== void 0) return;
1528
+ let run;
1529
+ try {
1530
+ run = this.ctx.agents.withoutInitiator(async () => {
1531
+ while (this.requested && !this.stopping) {
1532
+ this.requested = false;
1533
+ await this.transact(async () => {
1534
+ await this.drive();
1535
+ });
1536
+ }
1537
+ });
1538
+ } catch (error) {
1539
+ this.requested = false;
1540
+ this.ctx.logger.warn(`schedule: dispatch stopped: ${String(error)}`);
1541
+ return;
1542
+ }
1543
+ this.running = run;
1544
+ run.catch((error) => {
1545
+ this.ctx.logger.warn(`schedule: dispatch stopped: ${String(error)}`);
1546
+ }).finally(() => {
1547
+ this.running = void 0;
1548
+ if (this.requested && !this.stopping) this.requestDrive();
1549
+ });
1550
+ }
1551
+ /** Stop the timer and drain an accepted delivery before storage closes. */
1552
+ async dispose() {
1553
+ this.stopping = true;
1554
+ this.clearTimer();
1555
+ await this.running?.catch(() => void 0);
1556
+ }
1557
+ clearTimer() {
1558
+ if (this.timer !== void 0) clearTimeout(this.timer);
1559
+ this.timer = void 0;
1560
+ }
1561
+ async drive() {
1562
+ this.clearTimer();
1563
+ const failed = /* @__PURE__ */ new Set();
1564
+ const handled = /* @__PURE__ */ new Set();
1565
+ const scanNow = Date.now();
1566
+ const due = this.tasks().filter((task) => task.status === "active" && Date.parse(task.record.scheduledAt) <= scanNow);
1567
+ for (const task of due) {
1568
+ if (this.stopping) return;
1569
+ if (handled.has(task.record.id)) continue;
1570
+ const group = isRecurringScheduleRecord(task.record) ? due.filter((candidate) => candidate.sessionId === task.sessionId && isRecurringScheduleRecord(candidate.record)) : [task];
1571
+ for (const member of group) handled.add(member.record.id);
1572
+ let admitted = group;
1573
+ const committed = /* @__PURE__ */ new Set();
1574
+ try {
1575
+ const resolved = await this.ctx.sessionController.resolveAgent(task.sessionId);
1576
+ if ("error" in resolved) throw resolved.error;
1577
+ if (this.stopping) return;
1578
+ const now = Date.now();
1579
+ admitted = group.filter((member) => Date.parse(member.record.scheduledAt) <= now);
1580
+ if (admitted.length === 0) continue;
1581
+ const occurrences = admitted.filter((member) => isRecurringScheduleRecord(member.record)).map((member) => ({
1582
+ task: member,
1583
+ occurrence: resolveRecurringOccurrence(member.record, now)
1584
+ }));
1585
+ const message = createUserMessage({
1586
+ content: [{
1587
+ type: "text",
1588
+ text: isRecurringScheduleRecord(task.record) ? renderRecurringReminderBatchFraming(occurrences.map(({ task: member, occurrence }) => ({
1589
+ record: member.record,
1590
+ occurrenceAt: occurrence.occurrenceAt
1591
+ }))) : renderReminderFraming(task.record)
1592
+ }],
1593
+ source: { kind: "schedule" }
1594
+ });
1595
+ resolved.agent.followup(message);
1596
+ if (!await this.ctx.sessions.flush(resolved.agent.session)) throw new Error("Session persistence did not acknowledge the reminder");
1597
+ const deliveredAt = new Date(Date.now()).toISOString();
1598
+ if (!isRecurringScheduleRecord(task.record)) {
1599
+ await this.commit({
1600
+ ...task,
1601
+ status: "inactive",
1602
+ ...appendDelivery(task, {
1603
+ scheduledAt: task.record.scheduledAt,
1604
+ deliveredAt,
1605
+ messageId: message.id
1606
+ }, this.retention)
1607
+ });
1608
+ committed.add(task.record.id);
1609
+ }
1610
+ for (const { task: member, occurrence } of occurrences) {
1611
+ await this.commit({
1612
+ ...member,
1613
+ record: {
1614
+ ...member.record,
1615
+ scheduledAt: occurrence.nextScheduledAt ?? occurrence.occurrenceAt
1616
+ },
1617
+ status: occurrence.nextScheduledAt === void 0 ? "inactive" : "active",
1618
+ ...appendDelivery(member, {
1619
+ scheduledAt: occurrence.occurrenceAt,
1620
+ deliveredAt,
1621
+ messageId: message.id
1622
+ }, this.retention)
1623
+ });
1624
+ committed.add(member.record.id);
1625
+ }
1626
+ } catch (error) {
1627
+ const pending = admitted.filter((member) => !committed.has(member.record.id));
1628
+ const failedAt = Date.now();
1629
+ for (const member of pending) if (Date.parse(member.record.scheduledAt) <= failedAt) failed.add(member.record.id);
1630
+ const ids = pending.map((member) => member.record.id);
1631
+ this.ctx.logger.warn(`schedule: reminders ${JSON.stringify(ids)} were not acknowledged: ${String(error)}`);
1632
+ }
1633
+ }
1634
+ if (this.stopping) return;
1635
+ const next = this.tasks().filter((task) => task.status === "active" && !failed.has(task.record.id)).reduce((at, task) => {
1636
+ const target = Date.parse(task.record.scheduledAt);
1637
+ return at === void 0 ? target : Math.min(at, target);
1638
+ }, void 0);
1639
+ if (next !== void 0) {
1640
+ this.timer = setTimeout(() => {
1641
+ this.timer = void 0;
1642
+ this.requestDrive();
1643
+ }, Math.max(0, Math.min(next - Date.now(), MAX_TIMER_DELAY_MS)));
1644
+ this.timer.unref();
1645
+ }
1646
+ }
1647
+ };
1648
+ //#endregion
1649
+ //#region lib/types/storage.js
1650
+ /** Durable Host-wide Schedule tasks, independently of Session activation. */
1651
+ const recordSchema = z$1.unknown().transform((value, context) => {
1652
+ try {
1653
+ return decodeScheduleRecord(value);
1654
+ } catch (error) {
1655
+ context.addIssue({
1656
+ code: "custom",
1657
+ message: String(error)
1658
+ });
1659
+ }
1660
+ return z$1.NEVER;
1661
+ });
1662
+ const instantSchema = z$1.iso.datetime({ precision: 3 }).refine((value) => !value.startsWith("0000-"), { message: "Expected a canonical four-digit-year UTC calendar instant" });
1663
+ const deliveryReceiptSchema = z$1.object({
1664
+ scheduledAt: instantSchema,
1665
+ deliveredAt: instantSchema,
1666
+ messageId: z$1.string().min(1).refine((value) => value.trim() === value).transform(MessageId)
1667
+ }).strict();
1668
+ const deliveryHistorySchema = z$1.object({
1669
+ records: z$1.array(deliveryReceiptSchema.extend({ prompt: z$1.string().optional() }).strict()),
1670
+ earlierRecordsUnavailable: z$1.boolean(),
1671
+ earlierRecordsPruned: z$1.boolean().optional()
1672
+ }).strict().refine((history) => new Set(history.records.map((record) => record.messageId)).size === history.records.length, { message: "Delivery history message identities must be unique within a task" });
1673
+ /** Authoritative Schedule storage; malformed tasks reject opening the domain. */
1674
+ const scheduleDomain = defineDomain({
1675
+ name: "schedule",
1676
+ version: 1,
1677
+ tables: { tasks: domainTable(z$1.object({
1678
+ sessionId: z$1.string().min(1).transform(SessionId),
1679
+ record: recordSchema,
1680
+ status: z$1.enum(["active", "inactive"]).default("active"),
1681
+ lastDelivery: deliveryReceiptSchema.optional(),
1682
+ deliveryHistory: deliveryHistorySchema.optional()
1683
+ }).strict().refine((task) => {
1684
+ if (task.deliveryHistory === void 0) return true;
1685
+ const latest = task.deliveryHistory.records.at(-1);
1686
+ if (latest === void 0) return task.lastDelivery === void 0;
1687
+ return task.lastDelivery !== void 0 && latest.scheduledAt === task.lastDelivery.scheduledAt && latest.deliveredAt === task.lastDelivery.deliveredAt && latest.messageId === task.lastDelivery.messageId;
1688
+ }, { message: "Last delivery must match the latest saved delivery receipt" })) }
1689
+ });
1690
+ //#endregion
1691
+ //#region lib/types/update.js
1692
+ /** Optimistic name, instruction, and timing updates of retained Host tasks, without Session mutations. */
1693
+ /** Name the exact selector property each timing kind must carry, or undefined for an unknown discriminant. */
1694
+ function timingSelector(kind) {
1695
+ switch (kind) {
1696
+ case "at": return "at";
1697
+ case "every": return "every_seconds";
1698
+ case "daily": return "daily";
1699
+ case "weekly": return "weekly";
1700
+ case "cron": return "cron";
1701
+ default: return;
1702
+ }
1703
+ }
1704
+ /**
1705
+ * Validate the exact timing selector keys received over RPC.
1706
+ * @param value - Untrusted timing change; the wire can carry a null, array, or foreign discriminant.
1707
+ * @returns The same value narrowed to the closed timing-change union.
1708
+ */
1709
+ function validateChange(value) {
1710
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ScheduleInputError("invalid_rule", "Timing change must be an object with exactly one supported timing selector.");
1711
+ const change = value;
1712
+ const selector = timingSelector(change.kind);
1713
+ const keys = Object.keys(change);
1714
+ if (selector === void 0 || keys.length !== 2 || !keys.includes("kind") || !keys.includes(selector)) throw new ScheduleInputError("invalid_rule", "Timing change must contain exactly kind and its matching timing selector.");
1715
+ return change;
1716
+ }
1717
+ /**
1718
+ * Validate a replacement instruction received over RPC.
1719
+ * @param prompt - Untrusted instruction value; the wire can carry a non-string.
1720
+ * @returns The trimmed instruction; an invalid instruction throws ScheduleInputError.
1721
+ */
1722
+ function schedulePrompt(prompt) {
1723
+ if (typeof prompt !== "string" || prompt.trim().length === 0) throw new ScheduleInputError("invalid_prompt", "prompt must be non-empty after trimming.");
1724
+ return prompt.trim();
1725
+ }
1726
+ /**
1727
+ * Title one update must either keep or replace.
1728
+ *
1729
+ * A decoded record always carries a stored title. A record value without a
1730
+ * valid one is refused with the durable decode error instead of deriving a name.
1731
+ * @param record - Current active Host record.
1732
+ * @returns The stored title; a missing, blank, untrimmed, or over-long title throws ScheduleLogError.
1733
+ */
1734
+ function retainedTitle(record) {
1735
+ return decodeStoredTitle(record.title);
1736
+ }
1737
+ /**
1738
+ * Apply a replacement name and instruction to a stored record without touching its rule.
1739
+ * @param current - Current active record holding the committed target.
1740
+ * @param title - Validated replacement or retained name.
1741
+ * @param prompt - Validated replacement or retained instruction.
1742
+ * @returns The same record when both are unchanged, otherwise a frozen copy with the new content.
1743
+ */
1744
+ function withContent(current, title, prompt) {
1745
+ return title === current.title && prompt === current.prompt ? current : Object.freeze({
1746
+ ...current,
1747
+ title,
1748
+ prompt
1749
+ });
1750
+ }
1751
+ /**
1752
+ * Resolve the requested rule against the current record after the complete expected record has matched.
1753
+ *
1754
+ * The change kind may differ from the current record's kind; a change whose normalized
1755
+ * timing equals the current rule keeps the committed target and only applies the supplied
1756
+ * name and instruction, while any other change recomputes the target exactly as creation
1757
+ * computes it from the same now.
1758
+ */
1759
+ function changedRecord(current, title, prompt, value, now) {
1760
+ if (value === void 0) return withContent(current, title, prompt);
1761
+ const change = validateChange(value);
1762
+ switch (change.kind) {
1763
+ case "at":
1764
+ if ((current.kind === "after" || current.kind === "at") && parseAtInput(change.at) === Date.parse(current.scheduledAt)) return withContent(current, title, prompt);
1765
+ return createAtScheduleRecord(current.id, prompt, change.at, now, title);
1766
+ case "every":
1767
+ if (current.kind === "every" && change.every_seconds === current.everySeconds) return withContent(current, title, prompt);
1768
+ return createEveryScheduleRecord(current.id, prompt, change.every_seconds, now, title);
1769
+ case "daily":
1770
+ if (current.kind === "daily") {
1771
+ const normalized = parseDailyInput(change.daily);
1772
+ if (normalized.time === current.time && normalized.timeZone === canonicalizeTimeZone(current.timeZone)) return withContent(current, title, prompt);
1773
+ }
1774
+ return createDailyScheduleRecord(current.id, prompt, change.daily, now, title);
1775
+ case "weekly":
1776
+ if (current.kind === "weekly") {
1777
+ const normalized = parseWeeklyInput(change.weekly);
1778
+ if (normalized.time === current.time && normalized.timeZone === canonicalizeTimeZone(current.timeZone) && isDeepStrictEqual(normalized.weekdays, current.weekdays)) return withContent(current, title, prompt);
1779
+ }
1780
+ return createWeeklyScheduleRecord(current.id, prompt, change.weekly, now, title);
1781
+ case "cron":
1782
+ if (current.kind === "cron") {
1783
+ const normalized = parseCronInput(change.cron);
1784
+ if (normalized.expression === canonicalizeCronExpression(current.expression) && normalized.timeZone === canonicalizeTimeZone(current.timeZone)) return withContent(current, title, prompt);
1785
+ }
1786
+ return createCronScheduleRecord(current.id, prompt, change.cron, now, title);
1787
+ /* v8 ignore next 4 -- validateChange rejects unknown discriminants before this closed union switch. */
1788
+ default: throw new Error(`Unknown validated timing change: ${String(change)}`);
1789
+ }
1790
+ }
1791
+ /**
1792
+ * Compare the complete observed record and resolve one name, instruction, and timing update
1793
+ * using a single queue-time sample.
1794
+ *
1795
+ * An omitted change, name, or instruction keeps the stored value. A supplied name or
1796
+ * instruction never re-anchors the schedule on its own; an equivalent normalized timing
1797
+ * change keeps the committed target too.
1798
+ * @param current - Current active Host record.
1799
+ * @param expected - Untrusted complete record observed by the caller.
1800
+ * @param change - Strict timing selector, whose kind may differ from the current record's kind, or undefined to keep timing.
1801
+ * @param now - Single wall-clock sample from the accepted FIFO slot.
1802
+ * @param content - Untrusted replacement name and instruction; each omitted field keeps its stored value.
1803
+ * @returns Current/new record or a bounded input/conflict result; unrelated failures throw.
1804
+ */
1805
+ function resolveScheduleUpdate(current, expected, change, now, content = {}) {
1806
+ let decoded;
1807
+ try {
1808
+ decoded = decodeScheduleRecord(expected);
1809
+ } catch (error) {
1810
+ if (!(error instanceof ScheduleLogError)) throw error;
1811
+ return {
1812
+ code: "invalid_rule",
1813
+ message: "expected must be a complete valid Schedule record."
1814
+ };
1815
+ }
1816
+ if (!isDeepStrictEqual(current, decoded)) return {
1817
+ id: current.id,
1818
+ updated: false,
1819
+ code: "schedule_conflict"
1820
+ };
1821
+ try {
1822
+ const retained = retainedTitle(current);
1823
+ const record = changedRecord(current, content.title === void 0 ? retained : scheduleTitle(content.title), content.prompt === void 0 ? current.prompt : schedulePrompt(content.prompt), change, now);
1824
+ return {
1825
+ id: current.id,
1826
+ updated: record !== current,
1827
+ record
1828
+ };
1829
+ } catch (error) {
1830
+ if (!(error instanceof ScheduleInputError)) throw error;
1831
+ return {
1832
+ code: error.code,
1833
+ message: error.message
1834
+ };
1835
+ }
1836
+ }
1837
+ //#endregion
1838
+ //#region lib/types/index.js
1839
+ /** Host-wide durable reminders and shared human/model management. */
1840
+ var __runInitializers = function(thisArg, initializers, value) {
1841
+ var useValue = arguments.length > 2;
1842
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
1843
+ return useValue ? value : void 0;
1844
+ };
1845
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
1846
+ function accept(f) {
1847
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
1848
+ return f;
1849
+ }
1850
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
1851
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
1852
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
1853
+ var _, done = false;
1854
+ for (var i = decorators.length - 1; i >= 0; i--) {
1855
+ var context = {};
1856
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
1857
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
1858
+ context.addInitializer = function(f) {
1859
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
1860
+ extraInitializers.push(accept(f || null));
1861
+ };
1862
+ var result = (0, decorators[i])(kind === "accessor" ? {
1863
+ get: descriptor.get,
1864
+ set: descriptor.set
1865
+ } : descriptor[key], context);
1866
+ if (kind === "accessor") {
1867
+ if (result === void 0) continue;
1868
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
1869
+ if (_ = accept(result.get)) descriptor.get = _;
1870
+ if (_ = accept(result.set)) descriptor.set = _;
1871
+ if (_ = accept(result.init)) initializers.unshift(_);
1872
+ } else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
1873
+ else descriptor[key] = _;
1874
+ }
1875
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
1876
+ done = true;
1877
+ };
1878
+ /** Retained delivery-history window applied when the deployment states none. */
1879
+ const DEFAULT_DELIVERY_HISTORY_DAYS = 30;
1880
+ /** Retained delivery-history record cap applied when the deployment states none. */
1881
+ const DEFAULT_DELIVERY_HISTORY_RECORDS = 200;
1882
+ /**
1883
+ * Shared management service; reads, deletion, and timing edits never activate a Session.
1884
+ *
1885
+ * `sessionPersistence` is a load-order requirement rather than a directly called
1886
+ * service: a delivery commits only when `ctx.sessions.flush()` reports that a
1887
+ * `session/flush` listener participated, and the persistence backend providing this
1888
+ * service is the plugin that registers that listener.
1889
+ */
1890
+ let ScheduleService = (() => {
1891
+ let _classSuper = TypertRemoteService;
1892
+ let _instanceExtraInitializers = [];
1893
+ let _list_decorators;
1894
+ let _catalog_decorators;
1895
+ let _history_decorators;
1896
+ let _delete_decorators;
1897
+ let _update_decorators;
1898
+ return class ScheduleService extends _classSuper {
1899
+ static {
1900
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
1901
+ _list_decorators = [Remote("list")];
1902
+ _catalog_decorators = [Remote("catalog")];
1903
+ _history_decorators = [Remote("history")];
1904
+ _delete_decorators = [Remote("delete")];
1905
+ _update_decorators = [Remote("update")];
1906
+ __esDecorate(this, null, _list_decorators, {
1907
+ kind: "method",
1908
+ name: "list",
1909
+ static: false,
1910
+ private: false,
1911
+ access: {
1912
+ has: (obj) => "list" in obj,
1913
+ get: (obj) => obj.list
1914
+ },
1915
+ metadata: _metadata
1916
+ }, null, _instanceExtraInitializers);
1917
+ __esDecorate(this, null, _catalog_decorators, {
1918
+ kind: "method",
1919
+ name: "catalog",
1920
+ static: false,
1921
+ private: false,
1922
+ access: {
1923
+ has: (obj) => "catalog" in obj,
1924
+ get: (obj) => obj.catalog
1925
+ },
1926
+ metadata: _metadata
1927
+ }, null, _instanceExtraInitializers);
1928
+ __esDecorate(this, null, _history_decorators, {
1929
+ kind: "method",
1930
+ name: "history",
1931
+ static: false,
1932
+ private: false,
1933
+ access: {
1934
+ has: (obj) => "history" in obj,
1935
+ get: (obj) => obj.history
1936
+ },
1937
+ metadata: _metadata
1938
+ }, null, _instanceExtraInitializers);
1939
+ __esDecorate(this, null, _delete_decorators, {
1940
+ kind: "method",
1941
+ name: "delete",
1942
+ static: false,
1943
+ private: false,
1944
+ access: {
1945
+ has: (obj) => "delete" in obj,
1946
+ get: (obj) => obj.delete
1947
+ },
1948
+ metadata: _metadata
1949
+ }, null, _instanceExtraInitializers);
1950
+ __esDecorate(this, null, _update_decorators, {
1951
+ kind: "method",
1952
+ name: "update",
1953
+ static: false,
1954
+ private: false,
1955
+ access: {
1956
+ has: (obj) => "update" in obj,
1957
+ get: (obj) => obj.update
1958
+ },
1959
+ metadata: _metadata
1960
+ }, null, _instanceExtraInitializers);
1961
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
1962
+ enumerable: true,
1963
+ configurable: true,
1964
+ writable: true,
1965
+ value: _metadata
1966
+ });
1967
+ }
1968
+ static inject = [
1969
+ "agents",
1970
+ "sessions",
1971
+ "tools",
1972
+ "storageDomain",
1973
+ "sessionController",
1974
+ "sessionPersistence"
1975
+ ];
1976
+ static Config = z.object({
1977
+ deliveryHistoryDays: z.number().step(1).min(1).max(3650).default(DEFAULT_DELIVERY_HISTORY_DAYS),
1978
+ deliveryHistoryRecords: z.number().step(1).min(1).max(1e4).default(DEFAULT_DELIVERY_HISTORY_RECORDS)
1979
+ });
1980
+ /** Resolved retention bounds shared with the runtime that appends acknowledgments. */
1981
+ retention = __runInitializers(this, _instanceExtraInitializers);
1982
+ ready;
1983
+ initialized;
1984
+ chain = Promise.resolve();
1985
+ runtime;
1986
+ stopping = false;
1987
+ /**
1988
+ * @param ctx - Host services owning storage, dispatch, and Session restoration.
1989
+ * @param config - Validated retention configuration for delivery history.
1990
+ */
1991
+ constructor(ctx, config) {
1992
+ super(ctx, "schedule");
1993
+ this.retention = {
1994
+ days: config.deliveryHistoryDays ?? DEFAULT_DELIVERY_HISTORY_DAYS,
1995
+ records: config.deliveryHistoryRecords ?? DEFAULT_DELIVERY_HISTORY_RECORDS
1996
+ };
1997
+ this.ready = ctx.storageDomain.open(scheduleDomain).then(async (domain) => {
1998
+ for (const [key, task] of domain.table("tasks").entries()) if (key !== task.record.id) {
1999
+ try {
2000
+ await domain.close();
2001
+ } catch (error) {
2002
+ ctx.logger.warn(`schedule: closing the domain after a key mismatch failed: ${String(error)}`);
2003
+ }
2004
+ throw new Error(`schedule: stored task key "${key}" differs from record id "${task.record.id}"`);
2005
+ }
2006
+ return domain;
2007
+ });
2008
+ this.initialized = ctx.effect(async () => {
2009
+ const domain = await this.ready;
2010
+ let cleanup;
2011
+ try {
2012
+ cleanup = ctx.effect(() => async () => {
2013
+ this.stopping = true;
2014
+ await this.runtime?.dispose();
2015
+ await this.chain;
2016
+ await domain.close();
2017
+ });
2018
+ } catch (error) {
2019
+ this.stopping = true;
2020
+ await domain.close();
2021
+ throw error;
2022
+ }
2023
+ const tasks = domain.table("tasks");
2024
+ this.runtime = new ScheduleRuntime(ctx, () => [...tasks.entries()].map(([, task]) => task), (work) => this.serialize(work), async (task) => {
2025
+ await tasks.put(task.record.id, task);
2026
+ this.emitChanged();
2027
+ }, this.retention);
2028
+ this.runtime.requestDrive();
2029
+ return cleanup;
2030
+ });
2031
+ ctx.on("session/created", (session) => {
2032
+ let activeLegacy = 0;
2033
+ try {
2034
+ activeLegacy = foldScheduleEvents(session.ownEvents()).active.length;
2035
+ } catch (error) {
2036
+ /* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */
2037
+ if (!(error instanceof ScheduleLogError)) throw error;
2038
+ ctx.logger.warn(`schedule: Session "${session.id}" historical events could not be read (${error.message}); the legacy reminder is ignored.`);
2039
+ return;
2040
+ }
2041
+ if (activeLegacy > 0) ctx.logger.warn(`schedule: Session "${session.id}" contains legacy reminders; recreate active reminders with schedule_create.`);
2042
+ }, { global: true });
2043
+ ctx.effect(() => {
2044
+ const activity = ctx.on("workspace/session-activity", async ({ sessionId }, next) => {
2045
+ const active = await this.list({ sessionId });
2046
+ const rest = await next();
2047
+ if (active.length === 0) return rest;
2048
+ return [{
2049
+ kind: "schedule",
2050
+ items: active.map((record) => ({
2051
+ id: record.id,
2052
+ label: record.title
2053
+ }))
2054
+ }, ...rest];
2055
+ });
2056
+ const stop = ctx.on("workspace/session-stop", async ({ sessionId }) => {
2057
+ await this.stopSessionTasks(sessionId);
2058
+ });
2059
+ return () => {
2060
+ stop();
2061
+ activity();
2062
+ };
2063
+ }, "schedule.archiveAdmission()");
2064
+ }
2065
+ async [Service.init]() {
2066
+ await this.initialized;
2067
+ }
2068
+ /**
2069
+ * Create a reminder bound to the caller-selected Session without activating it.
2070
+ *
2071
+ * The request must supply a title; a missing, blank-after-trim, or over-long
2072
+ * title rejects with `invalid_prompt` instead of deriving one from the prompt.
2073
+ * The record is built from the clock reading taken before the request joins the
2074
+ * serialized queue, so a create that waits behind a longer operation keeps its
2075
+ * request-time anchor and may already be due when the queue reaches it.
2076
+ * @param sessionId - Original Session receiving the reminder.
2077
+ * @param request - Validated tool selector, required title, and reminder content.
2078
+ * @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
2079
+ * @returns The durably stored schedule. Cancellation does not roll back an in-flight write.
2080
+ */
2081
+ async create(sessionId, request, signal) {
2082
+ if (Number(request.at !== void 0) + Number(request.after_seconds !== void 0) + Number(request.every_seconds !== void 0) + Number(request.daily !== void 0) + Number(request.weekly !== void 0) + Number(request.cron !== void 0) > 1) throw new ScheduleInputError("invalid_selector", "Exactly one reminder selector is required.");
2083
+ const title = scheduleTitle(request.title);
2084
+ const id = ScheduleId(`schedule-${randomUUID()}`);
2085
+ const now = Date.now();
2086
+ let record;
2087
+ if (request.at !== void 0) record = createAtScheduleRecord(id, request.prompt, request.at, now, title);
2088
+ else if (request.after_seconds !== void 0) record = createAfterScheduleRecord(id, request.prompt, request.after_seconds, now, title);
2089
+ else if (request.every_seconds !== void 0) record = createEveryScheduleRecord(id, request.prompt, request.every_seconds, now, title);
2090
+ else if (request.daily !== void 0) record = createDailyScheduleRecord(id, request.prompt, request.daily, now, title);
2091
+ else if (request.weekly !== void 0) record = createWeeklyScheduleRecord(id, request.prompt, request.weekly, now, title);
2092
+ else if (request.cron !== void 0) record = createCronScheduleRecord(id, request.prompt, request.cron, now, title);
2093
+ else throw new ScheduleInputError("invalid_selector", "Exactly one reminder selector is required.");
2094
+ return this.serialize(async () => {
2095
+ const domain = await this.getDomain();
2096
+ signal?.throwIfAborted();
2097
+ await domain.table("tasks").put(id, {
2098
+ sessionId,
2099
+ record,
2100
+ status: "active",
2101
+ deliveryHistory: {
2102
+ records: [],
2103
+ earlierRecordsUnavailable: false
2104
+ }
2105
+ });
2106
+ this.emitChanged();
2107
+ this.runtime?.requestDrive();
2108
+ return record;
2109
+ });
2110
+ }
2111
+ /**
2112
+ * Read the selected Session's active tasks without resuming its Agent.
2113
+ * @param request - Session whose task list is requested.
2114
+ * @returns Persisted reminders in storage order.
2115
+ */
2116
+ async list(request) {
2117
+ return [...(await this.getDomain()).table("tasks").entries()].filter(([, task]) => task.sessionId === request.sessionId && task.status === "active").map(([, task]) => task.record);
2118
+ }
2119
+ /**
2120
+ * Read all active and inactive Host reminders with their original Session bindings.
2121
+ * A deleted reminder has no row, so it is absent here.
2122
+ * Does not activate Sessions or read Session history.
2123
+ * @returns Reminders ordered by scheduledAt ascending, then lexicographically by id.
2124
+ */
2125
+ async catalog() {
2126
+ return [...(await this.getDomain()).table("tasks").entries()].map(([, task]) => ({
2127
+ ...task.record,
2128
+ sessionId: task.sessionId,
2129
+ status: task.status,
2130
+ ...task.lastDelivery === void 0 ? {} : { lastDelivery: task.lastDelivery }
2131
+ })).sort((a, b) => a.scheduledAt.localeCompare(b.scheduledAt) || a.id.localeCompare(b.id));
2132
+ }
2133
+ /**
2134
+ * Read saved inbox deliveries without activating or reading the original Session.
2135
+ * The task's own row supplies its binding, so its records stay readable through this lookup.
2136
+ * @param request - Session binding, task identity, explicit limit, and optional exclusive message cursor.
2137
+ * @returns Newest-first deliveries in append order, or a task/cursor lookup failure.
2138
+ * @throws ScheduleInputError when limit is not a safe integer from 1 through 100.
2139
+ */
2140
+ async history(request) {
2141
+ if (!Number.isSafeInteger(request.limit) || request.limit < 1 || request.limit > 100) throw new ScheduleInputError("invalid_rule", "Delivery history limit must be a safe integer from 1 through 100.");
2142
+ const task = (await this.getDomain()).table("tasks").get(request.id);
2143
+ if (task === void 0 || task.sessionId !== request.sessionId) return {
2144
+ id: request.id,
2145
+ code: "schedule_not_found"
2146
+ };
2147
+ return deliveryHistoryPage(task, request, this.retention);
2148
+ }
2149
+ /**
2150
+ * Delete one task belonging to the selected Session, leaving queued messages intact.
2151
+ *
2152
+ * The row is removed: the task no longer schedules, leaves `list` and `catalog`, and its
2153
+ * saved delivery records go with it.
2154
+ * @param request - Session and exact task identity.
2155
+ * @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
2156
+ * @returns Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write.
2157
+ */
2158
+ async delete(request, signal) {
2159
+ return this.serialize(async () => {
2160
+ const tasks = (await this.getDomain()).table("tasks");
2161
+ signal?.throwIfAborted();
2162
+ const current = tasks.get(request.id);
2163
+ if (current === void 0 || current.sessionId !== request.sessionId) return {
2164
+ id: request.id,
2165
+ deleted: false,
2166
+ code: "schedule_not_found"
2167
+ };
2168
+ await tasks.delete(request.id);
2169
+ this.emitChanged();
2170
+ this.runtime?.requestDrive();
2171
+ return {
2172
+ id: request.id,
2173
+ deleted: true
2174
+ };
2175
+ });
2176
+ }
2177
+ /**
2178
+ * Update the name, instruction, and timing of an active task within the original Session
2179
+ * binding without activating the Session or changing saved deliveries.
2180
+ *
2181
+ * Each supplied field replaces its stored value; an omitted field keeps it. A name or
2182
+ * instruction change alone does not reset the committed target.
2183
+ * @param request - Task binding, complete observed record, and any combination of timing, name, and instruction.
2184
+ * @param signal - Cancellation checked after domain readiness and FIFO waits, before persistence begins.
2185
+ * @returns The committed record, unchanged record for a no-op, or a non-mutating input/lookup/conflict result.
2186
+ * Storage and lifecycle failures reject; cancellation after a write starts does not roll it back.
2187
+ */
2188
+ async update(request, signal) {
2189
+ return this.serialize(async () => {
2190
+ const tasks = (await this.getDomain()).table("tasks");
2191
+ signal?.throwIfAborted();
2192
+ const current = tasks.get(request.id);
2193
+ if (current === void 0 || current.sessionId !== request.sessionId) return {
2194
+ id: request.id,
2195
+ updated: false,
2196
+ code: "schedule_not_found"
2197
+ };
2198
+ if (current.status === "inactive") return {
2199
+ id: request.id,
2200
+ updated: false,
2201
+ code: "schedule_ended"
2202
+ };
2203
+ const result = resolveScheduleUpdate(current.record, request.expected, request.change, Date.now(), request);
2204
+ if (!("record" in result) || !result.updated) return result;
2205
+ await tasks.put(request.id, {
2206
+ ...current,
2207
+ record: result.record
2208
+ });
2209
+ this.emitChanged();
2210
+ this.runtime?.requestDrive();
2211
+ return result;
2212
+ });
2213
+ }
2214
+ /**
2215
+ * Dispatch one post-commit `schedule/changed` notification, containing
2216
+ * synchronous listener failures: every call site emits only after its durable
2217
+ * task write landed, so a throwing listener must not reject the caller or
2218
+ * skip the following `requestDrive()`.
2219
+ */
2220
+ emitChanged() {
2221
+ try {
2222
+ this.ctx.emit("schedule/changed");
2223
+ } catch (error) {
2224
+ this.ctx.logger.warn(`schedule: schedule/changed listener failed: ${String(error)}`);
2225
+ }
2226
+ }
2227
+ async getDomain() {
2228
+ await this.initialized;
2229
+ return this.ready;
2230
+ }
2231
+ /**
2232
+ * Remove every active task stored for one Session, inside the queue the tools
2233
+ * use.
2234
+ *
2235
+ * Enumerating and deleting in one queue slot is what makes an archive stop
2236
+ * ordered behind a create whose write is still in flight: a stop that read the
2237
+ * table outside the queue could miss a row the create was about to commit and
2238
+ * leave an armed reminder behind. Re-entering the public `delete()` from here
2239
+ * would deadlock on this queue, so the rows are removed directly.
2240
+ * @param sessionId - Session whose active Host tasks must stop.
2241
+ */
2242
+ async stopSessionTasks(sessionId) {
2243
+ await this.serialize(async () => {
2244
+ const tasks = (await this.getDomain()).table("tasks");
2245
+ const active = [...tasks.entries()].filter(([, task]) => task.sessionId === sessionId && task.status === "active").map(([, task]) => task.record.id);
2246
+ if (active.length === 0) return;
2247
+ let removed = false;
2248
+ let failure;
2249
+ for (const id of active) try {
2250
+ await tasks.delete(id);
2251
+ removed = true;
2252
+ } catch (error) {
2253
+ failure ??= error instanceof Error ? error : /* @__PURE__ */ new Error(`schedule stop failed: ${String(error)}`);
2254
+ }
2255
+ if (removed) {
2256
+ this.emitChanged();
2257
+ this.runtime?.requestDrive();
2258
+ }
2259
+ if (failure !== void 0) throw failure;
2260
+ });
2261
+ }
2262
+ serialize(work) {
2263
+ if (this.stopping) return Promise.reject(/* @__PURE__ */ new Error("Schedule service is stopping"));
2264
+ const pending = this.chain.then(work);
2265
+ this.chain = pending.catch(() => void 0);
2266
+ return pending;
2267
+ }
2268
+ };
2269
+ })();
2270
+ //#endregion
2271
+ export { MAX_TITLE_LENGTH, MIN_EVERY_INTERVAL_SECONDS, REQUIRED_TITLE_MESSAGE, SCHEDULE_CHANGE_VERSION, ScheduleId, ScheduleInputError, ScheduleLogError, ScheduleService, ScheduleService as default, canonicalizeCronExpression, createAfterScheduleRecord, createAtScheduleRecord, createCronScheduleRecord, createDailyScheduleRecord, createEveryScheduleRecord, createWeeklyScheduleRecord, decodeScheduleChange, decodeScheduleRecord, foldScheduleEvents, isRecurringScheduleRecord, normalizeWeekdays, parseCronInput, parseWeeklyInput, renderRecurringReminderBatchFraming, renderReminderFraming, resolveCronOccurrence, resolveDailyOccurrence, resolveEveryOccurrence, resolveRecurringOccurrence, resolveWeeklyOccurrence, scheduleDomain, scheduleTitle, scheduleView, weeklyTime };