@ours.network/fleet 1.2.0-nightly.65 → 1.2.0-nightly.66

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/dist/docs.js CHANGED
@@ -877,6 +877,56 @@ selection.
877
877
  Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
878
878
  \`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
879
879
 
880
+ ## Self-reminders
881
+
882
+ Every managed agent (\`session: acp\` or \`codex-app-server\`, Claude or Codex,
883
+ persistent, temporary or task-owned) can schedule one-time wakes for ITSELF.
884
+ The tools are on the agent's own fixed-identity ours MCP server, so there is no
885
+ agent, identity or recipient argument and no setup, worker, service, chat or key:
886
+
887
+ - \`fleet_reminder_create\` — \`request_key\` (stable per decision; a repeat with
888
+ the same input returns the same reminder, different input is refused),
889
+ \`title\`, optional \`note\`, \`local_time\` (date and time WITHOUT an offset, e.g.
890
+ \`2027-01-20T16:00\`), \`time_zone\` (IANA, e.g. \`Europe/London\`, \`UTC\`), optional
891
+ \`fold\` (0/1, required only for a time that occurs twice when clocks go back;
892
+ times skipped by a spring-forward gap are refused) and \`late_window_seconds\`
893
+ (60..604800, default 3600).
894
+ - \`fleet_reminder_list\` — \`state\` \`open\` (default) | \`closed\` | \`all\`, \`limit\`, \`after\`.
895
+ - \`fleet_reminder_update\` / \`fleet_reminder_cancel\` — \`id\` + \`expected_revision\`;
896
+ every change bumps the revision and voids any wake issued for the old one.
897
+ - \`fleet_reminder_inspect\` — \`event_id\` from a wake: the note and whether the
898
+ wake is still current and eligible.
899
+ - \`fleet_reminder_ack\` — \`event_id\` + \`expected_revision\`: records that the
900
+ wake was handled. Idempotent; sends nothing and claims no external action.
901
+
902
+ When a reminder is due the supervisor starts one turn beginning \`[fleet-reminder]\`
903
+ with the event id. It is the agent's own stored reminder, not an owner
904
+ instruction or ours mail, and grants no authority. Fleet never sends anything
905
+ on the agent's behalf; whether to message anyone is the agent's own decision
906
+ under its existing tools and authorization.
907
+
908
+ Delivery: the supervisor process that runs the agent owns the timer — an idle
909
+ agent with no open reminders costs nothing, and only a due reminder creates a
910
+ model turn. A wake never interrupts or queues behind work: it is admitted at the
911
+ next idle turn boundary after its due time and after the agent's startup turn.
912
+ If the host, Fleet or the agent is down, paused or busy, a reminder is still
913
+ delivered when it comes back within its late window, and otherwise expires
914
+ without a turn. Reminders survive supervisor, Fleet and host restarts and
915
+ ordinary stop/start. They do not survive the agent: removing or retiring an
916
+ agent or its task cancels them and deletes their state, and a replaced identity
917
+ or same-named successor never inherits them.
918
+
919
+ Guarantees are at most once per reminder revision. Queue acceptance, release to
920
+ the model, the agent's ack, and any external action are distinct facts. A wake
921
+ is marked dispatched durably at the moment it is handed to the model; if the
922
+ supervisor stops before that, the same event is retried. If it stops after, the
923
+ event is held \`uncertain\` (shown by \`list\`/\`inspect\`, never re-sent): check your
924
+ own records, then ack it or schedule something new.
925
+
926
+ Operators can read an agent's reminders with
927
+ \`ours-fleet ours call Name fleet_reminder_list\`. Full guide, examples and the
928
+ migration from the standalone \`self-reminders\` worker skill: \`docs/self-reminders.md\`.
929
+
880
930
  ## Trusted owner channel
881
931
 
882
932
  Any managed role may declare a separate ours identity which fleet — never the agent —
@@ -0,0 +1,68 @@
1
+ import type { ReminderAttempt } from '../session/arbiter.js';
2
+ import type { PromptOrigin } from '../session/types.js';
3
+ import type { ReminderEvent, ReminderRecord, ReminderStore } from './store.js';
4
+ /** The admission seam: the role's RoleTurnArbiter in production. */
5
+ export interface ReminderAdmitter {
6
+ tryReminder(text: string, origin: Extract<PromptOrigin, {
7
+ kind: 'self-reminder';
8
+ }>, beforeQueue: () => boolean, releaseGuard: () => boolean): Promise<ReminderAttempt>;
9
+ }
10
+ export interface ReminderManagerDeps {
11
+ now(): number;
12
+ setTimer(callback: () => void, ms: number): unknown;
13
+ clearTimer(timer: unknown): void;
14
+ log(line: string): void;
15
+ }
16
+ /**
17
+ * The dispatcher never sleeps longer than this while reminders are open, so a
18
+ * wall-clock jump (host suspend, NTP step) is noticed within a minute. With
19
+ * nothing open it arms no timer at all: an idle agent costs nothing.
20
+ */
21
+ export declare const REMINDER_POLL_CEILING_MS = 60000;
22
+ export interface ReminderManagerHandle {
23
+ start(): void;
24
+ stop(): void;
25
+ /** Called after an agent tool changed the schedule so the timer reflects it. */
26
+ changed(): void;
27
+ }
28
+ /**
29
+ * Turns due self-reminders into wakes for the agent that owns them.
30
+ *
31
+ * Runs inside that agent's runner, beside the role's turn arbiter. It starts
32
+ * only after the role's startup turn has succeeded and stops before the runner
33
+ * releases its runtime binding. Admission is idle-only: a wake never
34
+ * interrupts or queues behind work, and the session's release guard makes the
35
+ * durable `dispatched` claim at the moment the prompt reaches the model.
36
+ */
37
+ export declare class ReminderManager implements ReminderManagerHandle {
38
+ private readonly role;
39
+ private readonly store;
40
+ private readonly admitter;
41
+ private readonly deps;
42
+ private timer?;
43
+ private started;
44
+ private stopping;
45
+ private polling;
46
+ private busyStreak;
47
+ /** Consecutive re-arms with work already overdue; drives the spin backoff. */
48
+ private overdueStreak;
49
+ private readonly inFlight;
50
+ constructor(role: string, store: ReminderStore, admitter: ReminderAdmitter, deps: ReminderManagerDeps);
51
+ start(): void;
52
+ /** Synchronous: after this returns no timer is armed and every release guard refuses. */
53
+ stop(): void;
54
+ get isStopping(): boolean;
55
+ changed(): void;
56
+ /** Public fake-clock seam; the timer calls the same transition. */
57
+ poll(): Promise<void>;
58
+ private admit;
59
+ private guard;
60
+ private retryAt;
61
+ private schedule;
62
+ }
63
+ /**
64
+ * The wake text. It carries the agent's own title so the wake is legible, but
65
+ * the note is only returned by `fleet_reminder_inspect`, which also re-checks
66
+ * eligibility at the moment the agent acts.
67
+ */
68
+ export declare function wakePrompt(event: ReminderEvent, reminder: ReminderRecord, now: number, retry: boolean): string;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The dispatcher never sleeps longer than this while reminders are open, so a
3
+ * wall-clock jump (host suspend, NTP step) is noticed within a minute. With
4
+ * nothing open it arms no timer at all: an idle agent costs nothing.
5
+ */
6
+ export const REMINDER_POLL_CEILING_MS = 60_000;
7
+ /** Busy role: re-check at the next idle boundary within these bounds. */
8
+ const BUSY_RETRY_MIN_MS = 2_000;
9
+ const BUSY_RETRY_MAX_MS = 30_000;
10
+ /** Admission refused before reaching the model: bounded backoff, inside the late window. */
11
+ const UNAVAILABLE_RETRY_MS = [5_000, 15_000, 30_000, 60_000];
12
+ /**
13
+ * Turns due self-reminders into wakes for the agent that owns them.
14
+ *
15
+ * Runs inside that agent's runner, beside the role's turn arbiter. It starts
16
+ * only after the role's startup turn has succeeded and stops before the runner
17
+ * releases its runtime binding. Admission is idle-only: a wake never
18
+ * interrupts or queues behind work, and the session's release guard makes the
19
+ * durable `dispatched` claim at the moment the prompt reaches the model.
20
+ */
21
+ export class ReminderManager {
22
+ role;
23
+ store;
24
+ admitter;
25
+ deps;
26
+ timer;
27
+ started = false;
28
+ stopping = false;
29
+ polling = false;
30
+ busyStreak = 0;
31
+ /** Consecutive re-arms with work already overdue; drives the spin backoff. */
32
+ overdueStreak = 0;
33
+ inFlight = new Set();
34
+ constructor(role, store, admitter, deps) {
35
+ this.role = role;
36
+ this.store = store;
37
+ this.admitter = admitter;
38
+ this.deps = deps;
39
+ }
40
+ start() {
41
+ if (this.started || this.stopping)
42
+ return;
43
+ this.started = true;
44
+ this.schedule(0);
45
+ }
46
+ /** Synchronous: after this returns no timer is armed and every release guard refuses. */
47
+ stop() {
48
+ this.stopping = true;
49
+ if (this.timer !== undefined)
50
+ this.deps.clearTimer(this.timer);
51
+ this.timer = undefined;
52
+ }
53
+ get isStopping() { return this.stopping; }
54
+ changed() {
55
+ if (this.started && !this.stopping && !this.polling)
56
+ this.schedule();
57
+ }
58
+ /** Public fake-clock seam; the timer calls the same transition. */
59
+ async poll() {
60
+ if (!this.started || this.stopping || this.polling)
61
+ return;
62
+ this.polling = true;
63
+ let delay;
64
+ try {
65
+ const ready = this.store.sweep(this.inFlight);
66
+ for (const event of ready) {
67
+ if (this.stopping)
68
+ return;
69
+ const outcome = await this.admit(event);
70
+ if (outcome === 'busy') {
71
+ this.busyStreak++;
72
+ delay = Math.min(BUSY_RETRY_MAX_MS, BUSY_RETRY_MIN_MS * 2 ** Math.min(this.busyStreak - 1, 4));
73
+ break;
74
+ }
75
+ this.busyStreak = 0;
76
+ // One wake per turn boundary: the next due reminder waits for the
77
+ // following poll, by which time this one's turn has claimed the role.
78
+ if (outcome === 'admitted') {
79
+ delay = 0;
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ catch (error) {
85
+ this.deps.log(`[${this.role}] self-reminder dispatch failed: ${error?.name ?? 'Error'}; retrying`);
86
+ delay = UNAVAILABLE_RETRY_MS[0];
87
+ }
88
+ finally {
89
+ this.polling = false;
90
+ }
91
+ this.schedule(delay);
92
+ }
93
+ async admit(event) {
94
+ const summary = this.store.eventSummary(event.id);
95
+ if (!summary)
96
+ return 'skipped';
97
+ const { reminder } = summary;
98
+ const origin = {
99
+ kind: 'self-reminder', eventId: event.id, reminderId: reminder.id, revision: event.revision,
100
+ };
101
+ const text = wakePrompt(event, reminder, this.deps.now(), event.attempts > 0);
102
+ this.inFlight.add(event.id);
103
+ let attempt;
104
+ try {
105
+ attempt = await this.admitter.tryReminder(text, origin, () => !this.stopping && this.store.markAdmitting(event.id), () => this.guard(event.id));
106
+ }
107
+ catch (error) {
108
+ this.inFlight.delete(event.id);
109
+ this.store.releaseUndispatched(event.id, this.retryAt(event));
110
+ throw error;
111
+ }
112
+ if (attempt.state === 'busy') {
113
+ this.inFlight.delete(event.id);
114
+ return 'busy';
115
+ }
116
+ if (attempt.state === 'unavailable') {
117
+ this.inFlight.delete(event.id);
118
+ this.store.releaseUndispatched(event.id, this.retryAt(event));
119
+ this.deps.log(`[${this.role}] self-reminder ${event.id.slice(0, 12)} not admitted; will retry within its window`);
120
+ return 'skipped';
121
+ }
122
+ // Own the completion before anything else can throw: the queued prompt
123
+ // ends this admission whatever happens to the receipt write below.
124
+ const settled = () => {
125
+ this.inFlight.delete(event.id);
126
+ // Settled without the guard dispatching it (offline session, refused at
127
+ // release while stopping): still undispatched, so it may be retried.
128
+ // A failed write leaves it admitting with no owner; sweep recovers that.
129
+ try {
130
+ this.store.releaseUndispatched(event.id, this.retryAt(event));
131
+ }
132
+ catch { /* recovered by sweep */ }
133
+ this.changed();
134
+ };
135
+ void attempt.queued.completion.then(settled, settled);
136
+ // The receipt is diagnostic only; dispatch state is the release guard's.
137
+ try {
138
+ this.store.recordReceipt(event.id, attempt.queued.promptId);
139
+ }
140
+ catch {
141
+ this.deps.log(`[${this.role}] self-reminder ${event.id.slice(0, 12)} receipt not recorded`);
142
+ }
143
+ this.deps.log(`[${this.role}] self-reminder ${event.id.slice(0, 12)} admitted`);
144
+ return 'admitted';
145
+ }
146
+ guard(eventId) {
147
+ const result = this.store.claimDispatch(eventId, this.stopping);
148
+ if (!result.dispatch)
149
+ this.deps.log(`[${this.role}] self-reminder ${eventId.slice(0, 12)} not dispatched at release (${result.reason})`);
150
+ return result.dispatch;
151
+ }
152
+ retryAt(event) {
153
+ const step = UNAVAILABLE_RETRY_MS[Math.min(event.attempts, UNAVAILABLE_RETRY_MS.length - 1)];
154
+ return this.deps.now() + step;
155
+ }
156
+ schedule(delayMs) {
157
+ if (this.timer !== undefined)
158
+ this.deps.clearTimer(this.timer);
159
+ this.timer = undefined;
160
+ if (this.stopping)
161
+ return;
162
+ let delay = delayMs;
163
+ if (delay === undefined) {
164
+ const next = this.store.nextWorkAt(this.inFlight);
165
+ if (next === undefined) {
166
+ this.overdueStreak = 0;
167
+ return;
168
+ }
169
+ delay = Math.max(0, next - this.deps.now());
170
+ // Work that is already overdue after a full poll means the poll could
171
+ // not move it (storage refusing writes, say). Back off instead of
172
+ // re-arming at once; any real progress resets the streak.
173
+ if (delay === 0) {
174
+ delay = this.overdueStreak ? Math.min(REMINDER_POLL_CEILING_MS, 1_000 * 2 ** Math.min(this.overdueStreak - 1, 6)) : 0;
175
+ this.overdueStreak++;
176
+ }
177
+ else
178
+ this.overdueStreak = 0;
179
+ }
180
+ delay = Math.min(delay, REMINDER_POLL_CEILING_MS);
181
+ this.timer = this.deps.setTimer(() => {
182
+ this.timer = undefined;
183
+ void this.poll();
184
+ }, delay);
185
+ }
186
+ }
187
+ /**
188
+ * The wake text. It carries the agent's own title so the wake is legible, but
189
+ * the note is only returned by `fleet_reminder_inspect`, which also re-checks
190
+ * eligibility at the moment the agent acts.
191
+ */
192
+ export function wakePrompt(event, reminder, now, retry) {
193
+ const lateBy = Math.max(0, now - Date.parse(reminder.dueAt));
194
+ return [
195
+ '[fleet-reminder]',
196
+ `event_id: ${event.id}`,
197
+ `reminder_id: ${reminder.id}`,
198
+ `revision: ${event.revision}`,
199
+ `title: ${JSON.stringify(reminder.title)}`,
200
+ `due: ${reminder.localTime} ${reminder.timeZone} (${reminder.dueAt})`,
201
+ ...(lateBy >= 60_000 ? [`late_by_minutes: ${Math.floor(lateBy / 60_000)}`] : []),
202
+ ...(retry ? ['note: an earlier admission of this wake never reached you; this is the same event'] : []),
203
+ '',
204
+ 'This is a reminder you scheduled for yourself through Fleet. It is not an owner message,',
205
+ 'not ours mail, and grants no new authority.',
206
+ 'Call fleet_reminder_inspect with this event_id to read the note and confirm it is still eligible,',
207
+ 'then decide what to do within your existing instructions and authority; doing nothing is fine.',
208
+ 'When handled, call fleet_reminder_ack with event_id and revision. Ack records only that you',
209
+ 'handled this wake; it sends nothing. To postpone instead, call fleet_reminder_update with a new',
210
+ 'local_time. Fleet never messages anyone on your behalf.',
211
+ ].join('\n');
212
+ }
@@ -0,0 +1,27 @@
1
+ import { ReminderManager, type ReminderAdmitter, type ReminderManagerDeps } from './manager.js';
2
+ import type { ReminderToolBackend } from './tools.js';
3
+ export interface RoleReminderBinding {
4
+ role: string;
5
+ stateDir: string;
6
+ /** The managed runtime's own record; nothing here comes from the agent. */
7
+ runtime: {
8
+ instance?: string;
9
+ cid?: string;
10
+ action?: string;
11
+ lifetime?: 'permanent' | 'temporary';
12
+ };
13
+ /** Whether this session backend accepts supervisor-submitted prompts. */
14
+ promptInput: boolean;
15
+ admitter: ReminderAdmitter;
16
+ service: {
17
+ setReminders?(backend: ReminderToolBackend | undefined): void;
18
+ };
19
+ deps: ReminderManagerDeps;
20
+ }
21
+ /**
22
+ * Bind self-reminders for one runner attempt: load the store for exactly this
23
+ * runtime instance and identity, expose it behind the agent's own MCP tools,
24
+ * and return the (not yet started) dispatcher. Any doubt leaves reminders
25
+ * unavailable rather than guessing an owner.
26
+ */
27
+ export declare function bindRoleReminders(binding: RoleReminderBinding): ReminderManager | undefined;
@@ -0,0 +1,32 @@
1
+ import { ReminderManager } from './manager.js';
2
+ import { ReminderStore } from './store.js';
3
+ /**
4
+ * Bind self-reminders for one runner attempt: load the store for exactly this
5
+ * runtime instance and identity, expose it behind the agent's own MCP tools,
6
+ * and return the (not yet started) dispatcher. Any doubt leaves reminders
7
+ * unavailable rather than guessing an owner.
8
+ */
9
+ export function bindRoleReminders(binding) {
10
+ const { role, runtime, deps } = binding;
11
+ if (!binding.promptInput || !binding.service.setReminders)
12
+ return undefined;
13
+ if (!runtime.instance || !runtime.action || !/^[A-Fa-f0-9]{64}$/.test(runtime.cid ?? '')
14
+ || (runtime.lifetime !== 'permanent' && runtime.lifetime !== 'temporary')) {
15
+ deps.log(`[${role}] self-reminders unavailable: managed runtime identity is not established`);
16
+ return undefined;
17
+ }
18
+ const owner = {
19
+ role, lifetime: runtime.lifetime, instance: runtime.instance, cid: runtime.cid, action: runtime.action,
20
+ };
21
+ let store;
22
+ try {
23
+ store = new ReminderStore(binding.stateDir, owner, deps.now, deps.log);
24
+ }
25
+ catch (error) {
26
+ deps.log(`[${role}] self-reminders unavailable: state could not be opened (${error?.name ?? 'Error'})`);
27
+ return undefined;
28
+ }
29
+ const manager = new ReminderManager(role, store, binding.admitter, deps);
30
+ binding.service.setReminders({ store, changed: () => manager.changed() });
31
+ return manager;
32
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Wall-clock schedule resolution for self-reminders.
3
+ *
4
+ * A reminder is stated the way a person states it — a calendar date, a time of
5
+ * day and the IANA zone it is meant in — and stored as the one UTC instant that
6
+ * statement denotes. Nothing is inferred from the host's own zone: a time with
7
+ * an offset, a missing date or an unknown zone is refused rather than guessed.
8
+ *
9
+ * Daylight-saving transitions are the two places a wall time is not one
10
+ * instant. A time skipped by a spring-forward gap does not exist and is
11
+ * refused. A time repeated by a fall-back overlap exists twice, and the caller
12
+ * must say which with `fold` (0 = the first occurrence, 1 = the second), the
13
+ * same convention as Python's `datetime.fold`.
14
+ */
15
+ export declare class ReminderScheduleError extends Error {
16
+ readonly code: string;
17
+ constructor(code: string, message: string);
18
+ }
19
+ /** Canonical IANA zone name, or a typed refusal. Host defaults never apply. */
20
+ export declare function canonicalTimeZone(raw: unknown): string;
21
+ export interface ResolvedSchedule {
22
+ /** Canonical `YYYY-MM-DDTHH:MM:SS`, as the caller meant it. */
23
+ localTime: string;
24
+ timeZone: string;
25
+ /** Present only when the wall time was ambiguous and the caller chose. */
26
+ fold?: 0 | 1;
27
+ dueAt: number;
28
+ }
29
+ /**
30
+ * Resolve a wall time in a zone to its instant. Every offset the zone uses in
31
+ * the surrounding two days is a candidate; a candidate survives only if it
32
+ * reads back as the requested wall time. Zero survivors is a gap, two is an
33
+ * overlap. Zones change offset at most once in any such window in practice,
34
+ * and a reading that does not round-trip is never accepted, so the answer is
35
+ * never a guess.
36
+ */
37
+ export declare function resolveSchedule(localTime: unknown, timeZone: unknown, fold?: unknown): ResolvedSchedule;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Wall-clock schedule resolution for self-reminders.
3
+ *
4
+ * A reminder is stated the way a person states it — a calendar date, a time of
5
+ * day and the IANA zone it is meant in — and stored as the one UTC instant that
6
+ * statement denotes. Nothing is inferred from the host's own zone: a time with
7
+ * an offset, a missing date or an unknown zone is refused rather than guessed.
8
+ *
9
+ * Daylight-saving transitions are the two places a wall time is not one
10
+ * instant. A time skipped by a spring-forward gap does not exist and is
11
+ * refused. A time repeated by a fall-back overlap exists twice, and the caller
12
+ * must say which with `fold` (0 = the first occurrence, 1 = the second), the
13
+ * same convention as Python's `datetime.fold`.
14
+ */
15
+ export class ReminderScheduleError extends Error {
16
+ code;
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.code = code;
20
+ this.name = 'ReminderScheduleError';
21
+ }
22
+ }
23
+ const LOCAL_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
24
+ const HOUR_MS = 60 * 60 * 1000;
25
+ const formatters = new Map();
26
+ function formatter(timeZone) {
27
+ let value = formatters.get(timeZone);
28
+ if (!value) {
29
+ value = new Intl.DateTimeFormat('en-US', {
30
+ timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit',
31
+ hour: '2-digit', minute: '2-digit', second: '2-digit', era: 'short',
32
+ });
33
+ formatters.set(timeZone, value);
34
+ }
35
+ return value;
36
+ }
37
+ /** Canonical IANA zone name, or a typed refusal. Host defaults never apply. */
38
+ export function canonicalTimeZone(raw) {
39
+ if (typeof raw !== 'string' || !raw.trim() || raw.length > 64 || !/^[A-Za-z0-9_+\-/]+$/.test(raw))
40
+ throw new ReminderScheduleError('invalid_time_zone', 'time_zone must be an IANA zone name such as Europe/London or UTC');
41
+ try {
42
+ return new Intl.DateTimeFormat('en-US', { timeZone: raw }).resolvedOptions().timeZone;
43
+ }
44
+ catch {
45
+ throw new ReminderScheduleError('invalid_time_zone', `unknown IANA time zone '${raw}'`);
46
+ }
47
+ }
48
+ function parseLocalTime(raw) {
49
+ const match = typeof raw === 'string' ? LOCAL_TIME_RE.exec(raw) : null;
50
+ if (!match)
51
+ throw new ReminderScheduleError('invalid_local_time', 'local_time must be a calendar date and time without an offset, e.g. 2027-01-20T16:00 or 2027-01-20T16:00:00');
52
+ const [year, month, day, hour, minute, second] = match.slice(1).map(part => Number(part ?? 0));
53
+ const wall = { year: year, month: month, day: day, hour: hour, minute: minute, second: second };
54
+ // Date.UTC normalizes overflow (Feb 30 -> Mar 2); a round trip rejects it.
55
+ const probe = new Date(wallMs(wall));
56
+ if (wall.year < 1970 || wall.year > 9999 || probe.getUTCFullYear() !== wall.year
57
+ || probe.getUTCMonth() + 1 !== wall.month || probe.getUTCDate() !== wall.day
58
+ || wall.hour > 23 || wall.minute > 59 || wall.second > 59)
59
+ throw new ReminderScheduleError('invalid_local_time', `local_time '${String(raw)}' is not a real calendar date and time`);
60
+ return wall;
61
+ }
62
+ function wallMs(wall) {
63
+ return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
64
+ }
65
+ /** The wall-clock reading of `instant` in `timeZone`, expressed as naive UTC milliseconds. */
66
+ function wallAt(instant, timeZone) {
67
+ const parts = Object.fromEntries(formatter(timeZone).formatToParts(new Date(instant))
68
+ .map(part => [part.type, part.value]));
69
+ if (parts.era !== 'AD')
70
+ throw new ReminderScheduleError('invalid_local_time', 'dates before the common era are not supported');
71
+ return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second));
72
+ }
73
+ /**
74
+ * Resolve a wall time in a zone to its instant. Every offset the zone uses in
75
+ * the surrounding two days is a candidate; a candidate survives only if it
76
+ * reads back as the requested wall time. Zero survivors is a gap, two is an
77
+ * overlap. Zones change offset at most once in any such window in practice,
78
+ * and a reading that does not round-trip is never accepted, so the answer is
79
+ * never a guess.
80
+ */
81
+ export function resolveSchedule(localTime, timeZone, fold) {
82
+ const zone = canonicalTimeZone(timeZone);
83
+ const wall = parseLocalTime(localTime);
84
+ if (fold !== undefined && fold !== null && fold !== 0 && fold !== 1)
85
+ throw new ReminderScheduleError('invalid_fold', 'fold must be 0 (first occurrence) or 1 (second occurrence)');
86
+ const naive = wallMs(wall);
87
+ const offsets = new Set();
88
+ for (let probe = -48; probe <= 48; probe += 6) {
89
+ const instant = naive + probe * HOUR_MS;
90
+ offsets.add(wallAt(instant, zone) - instant);
91
+ }
92
+ const matches = [...new Set([...offsets].map(offset => naive - offset))]
93
+ .filter(instant => wallAt(instant, zone) === naive)
94
+ .sort((a, b) => a - b);
95
+ const canonical = new Date(naive).toISOString().slice(0, 19);
96
+ if (!matches.length)
97
+ throw new ReminderScheduleError('nonexistent_local_time', `${canonical} does not exist in ${zone} (skipped by a daylight-saving change); choose another time`);
98
+ if (matches.length > 1) {
99
+ if (fold !== 0 && fold !== 1)
100
+ throw new ReminderScheduleError('ambiguous_local_time', `${canonical} occurs twice in ${zone}; pass fold 0 for the first occurrence or 1 for the second`);
101
+ return { localTime: canonical, timeZone: zone, fold, dueAt: matches[fold] };
102
+ }
103
+ return { localTime: canonical, timeZone: zone, dueAt: matches[0] };
104
+ }