relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
1,117 lines (999 loc) • 44.5 kB
JavaScript
// The engine behind scheduled requests (RELAY_REQUESTS_SHIP_PLAN §10).
//
// Three entities, and this file only knows about the first two:
// Schedule — the standing contract (cadence, route, policies, state).
// Run — one instance of that contract, identified by a DETERMINISTIC id.
// Completion draft — one run's letter home (not this file's business).
//
// Everything here is pure: no filesystem, no network, no timers, no mutation of
// its arguments. The caller (task-daemon) owns persistence and spawning; this
// module answers "when", "which", "how many", and "under what id" — nothing else.
//
// THE ZONE LAW. A schedule fires on the RECIPIENT'S wall clock, so every
// calculation runs through Intl.DateTimeFormat with an IANA timeZone. A fixed
// UTC offset is never assumed anywhere in this file: "9:00 every day" is 13:00Z
// in a New York winter and 13:00Z-minus-an-hour in summer, and both are the same
// promise. DST edges obey §10.1 exactly:
// - a wall time that does NOT exist (spring forward) fires at the first valid
// instant — the moment the clock jumps over it;
// - a wall time that happens TWICE (fall back) fires ONCE, at the first.
// Never double-fire, never silently skip.
//
// THE ANCHOR. RFC 5545 hangs a recurrence off DTSTART. A Relay schedule has no
// DTSTART, so `createdAt` plays that role: it supplies INTERVAL's phase ("every
// 2 weeks" counted from where) and any day the rule leaves unsaid (WEEKLY with
// no BYDAY, MONTHLY with no BYMONTHDAY, YEARLY's month). createdAt — not
// acceptedAt — because the fire times shown on the accept sheet must be the ones
// that actually happen after Accept is pressed.
//
// TIME OF DAY. BYHOUR/BYMINUTE default to 0, not to the anchor's clock time: a
// schedule written without a time is midnight, which is what §10.1's "every 2nd
// of the month · 00:00" says, and it never surprises anyone with 14:37.
import { createHash } from "node:crypto";
const SECOND_MS = 1000;
const MINUTE_MS = 60 * SECOND_MS;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/** Policy floor from §10.1: no schedule may fire more often than this. */
export const MIN_FIRE_INTERVAL_MS = 5 * MINUTE_MS;
/** §10.3: `catchUp:"all"` replays sequentially capped at 10; the rest coalesce. */
export const CATCH_UP_REPLAY_CAP = 10;
/**
* How late a fire may be and still count as "on time" rather than "missed".
* The daemon polls on a few-second cadence; anything older than this window
* means the machine was away, which is what catchUp is a policy about.
*/
export const DEFAULT_GRACE_MS = 5 * MINUTE_MS;
/** The ledger's map name. task-daemon keeps other dedupe maps beside it. */
export const LEDGER_KEY = "scheduleRuns";
const RUN_TERMINAL_STATES = new Set(["done", "failed", "skipped"]);
const SCHEDULE_STATES = new Set([
"proposed",
"accepted",
"declined",
"paused",
"ended",
"revoked",
]);
/** States whose future is over — no preview, no fires. */
const DEAD_STATES = new Set(["declined", "ended", "revoked"]);
const CATCH_UP_POLICIES = new Set(["latest", "all", "skip"]);
const OVERLAP_POLICIES = new Set(["skip", "queue"]);
// Guard against an unsatisfiable rule scanning forever. Every loop step is one
// day, one skipped month, or one skipped year, so this covers decades.
const MAX_SCAN_STEPS = 20000;
// How many owed fires a single recovery will enumerate — about thirteen years
// of a daily schedule. Past this the coalesced count stops being interesting
// and the memory does not need to be spent.
const MAX_OWED_FIRES = 5000;
// ---------------------------------------------------------------------------
// Zone primitives
// ---------------------------------------------------------------------------
const formatterCache = new Map();
function zoneFormatter(timeZone) {
let fmt = formatterCache.get(timeZone);
if (!fmt) {
try {
fmt = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
throw new Error(`schedule: unknown timezone ${JSON.stringify(timeZone)}`);
}
formatterCache.set(timeZone, fmt);
}
return fmt;
}
/** The wall-clock fields an instant shows in a zone. */
function zonedParts(instantMs, timeZone) {
const parts = zoneFormatter(timeZone).formatToParts(new Date(instantMs));
const out = {};
for (const part of parts) {
if (part.type === "literal") continue;
out[part.type] = Number(part.value);
}
return {
year: out.year,
month: out.month,
day: out.day,
hour: out.hour,
minute: out.minute,
second: out.second,
};
}
function utcMsFromCivil(year, month, day, hour = 0, minute = 0, second = 0) {
const ms = Date.UTC(year, month - 1, day, hour, minute, second);
if (year >= 0 && year < 100) {
// Date.UTC maps 0–99 into the 1900s; undo that for the (never-in-practice)
// ancient case so the math stays honest.
const d = new Date(ms);
d.setUTCFullYear(year);
return d.getTime();
}
return ms;
}
/** The zone's offset from UTC, in ms, at a given instant. */
function zoneOffsetMs(instantMs, timeZone) {
const p = zonedParts(instantMs, timeZone);
const asIfUtc = utcMsFromCivil(p.year, p.month, p.day, p.hour, p.minute, p.second);
return asIfUtc - Math.floor(instantMs / SECOND_MS) * SECOND_MS;
}
/**
* Every instant that shows this wall time in this zone: two on a fall-back
* hour, one normally, NONE inside a spring-forward gap. (The Temporal
* "possible instants" algorithm: probe the offset a day either side, and keep
* only the candidates that round-trip back to the wall time asked for.)
*/
function wallToInstants(wall, timeZone) {
const guess = utcMsFromCivil(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second || 0);
const before = zoneOffsetMs(guess - DAY_MS, timeZone);
const after = zoneOffsetMs(guess + DAY_MS, timeZone);
const offsets = before === after ? [before] : [before, after];
const hits = [];
for (const offset of offsets) {
const ts = guess - offset;
const p = zonedParts(ts, timeZone);
if (
p.year === wall.year &&
p.month === wall.month &&
p.day === wall.day &&
p.hour === wall.hour &&
p.minute === wall.minute &&
p.second === (wall.second || 0)
) {
hits.push(ts);
}
}
return [...new Set(hits)].sort((a, b) => a - b);
}
/** The instant a zone's clock first reads >= the (nonexistent) wall time. */
function gapEndInstant(wall, timeZone) {
const guess = utcMsFromCivil(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second || 0);
let lo = guess - DAY_MS;
let hi = guess + DAY_MS;
const offLo = zoneOffsetMs(lo, timeZone);
if (zoneOffsetMs(hi, timeZone) === offLo) return guess - offLo; // no transition: shouldn't happen
while (hi - lo > SECOND_MS) {
const mid = lo + Math.floor((hi - lo) / 2 / SECOND_MS) * SECOND_MS;
if (mid === lo) break;
if (zoneOffsetMs(mid, timeZone) === offLo) lo = mid;
else hi = mid;
}
return hi;
}
/**
* The single instant a wall time fires at, per the DST law:
* doubled → the FIRST of the two; nonexistent → the first valid instant.
*/
function wallToInstant(wall, timeZone) {
const hits = wallToInstants(wall, timeZone);
if (hits.length) return hits[0];
return gapEndInstant(wall, timeZone);
}
function offsetLabel(offsetMs) {
const sign = offsetMs < 0 ? "-" : "+";
const abs = Math.abs(offsetMs);
const hours = Math.floor(abs / HOUR_MS);
const minutes = Math.floor((abs % HOUR_MS) / MINUTE_MS);
return `${sign}${pad2(hours)}:${pad2(minutes)}`;
}
function pad2(n) {
return String(n).padStart(2, "0");
}
/**
* An instant as an ISO-8601 string in the schedule's zone — wall time the
* recipient recognises, offset attached so the instant stays unambiguous.
* e.g. 2026-03-09T09:00:00-04:00
*/
function formatZonedISO(instantMs, timeZone) {
const p = zonedParts(instantMs, timeZone);
const offset = zoneOffsetMs(instantMs, timeZone);
return (
`${String(p.year).padStart(4, "0")}-${pad2(p.month)}-${pad2(p.day)}` +
`T${pad2(p.hour)}:${pad2(p.minute)}:${pad2(p.second)}${offsetLabel(offset)}`
);
}
// ---------------------------------------------------------------------------
// Civil-calendar helpers (pure day arithmetic, no zone involved)
// ---------------------------------------------------------------------------
function epochDayFromCivil(year, month, day) {
return Math.floor(utcMsFromCivil(year, month, day) / DAY_MS);
}
function civilFromEpochDay(epochDay) {
const d = new Date(epochDay * DAY_MS);
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
/** 0 = Sunday … 6 = Saturday. Epoch day 0 (1970-01-01) was a Thursday. */
function weekdayFromEpochDay(epochDay) {
return (((epochDay % 7) + 7) % 7 + 4) % 7;
}
/** Monday-started week index (RFC's default WKST=MO). */
function weekIndexFromEpochDay(epochDay) {
return Math.floor((epochDay + 3) / 7);
}
function daysInMonth(year, month) {
return new Date(utcMsFromCivil(month === 12 ? year + 1 : year, month === 12 ? 1 : month + 1, 1) - DAY_MS).getUTCDate();
}
/**
* The nth weekday inside a day range (BYDAY ordinals): ordinal 2 = the second,
* -1 = the last. Returns an epoch day, or null when the range has no such one.
*/
function nthWeekdayEpochDay(startDay, endDay, weekday, ordinal) {
if (ordinal > 0) {
const first = startDay + ((weekday - weekdayFromEpochDay(startDay) + 7) % 7);
const hit = first + 7 * (ordinal - 1);
return hit <= endDay ? hit : null;
}
const last = endDay - ((weekdayFromEpochDay(endDay) - weekday + 7) % 7);
const hit = last + 7 * (ordinal + 1);
return hit >= startDay ? hit : null;
}
function mod(a, n) {
return ((a % n) + n) % n;
}
// ---------------------------------------------------------------------------
// RRULE
// ---------------------------------------------------------------------------
const FREQS = new Set(["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]);
const DAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
const DAY_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const DAY_SHORT = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const MONTH_NAMES = [
"january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december",
];
const MONTH_SHORT = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
const SUPPORTED_PARTS = new Set([
"FREQ", "INTERVAL", "BYDAY", "BYMONTHDAY", "BYHOUR", "BYMINUTE", "COUNT", "UNTIL",
]);
// Real RFC 5545 parts we deliberately do not implement. Named separately so the
// error says "not supported" rather than "unknown" — the difference between
// "you wrote nonsense" and "Relay can't keep that promise yet".
const KNOWN_UNSUPPORTED = new Set([
"BYSECOND", "BYMONTH", "BYYEARDAY", "BYWEEKNO", "BYSETPOS", "WKST", "DTSTART", "TZID", "RSCALE", "SKIP",
]);
function fail(message) {
throw new Error(`rrule: ${message}`);
}
function parseIntStrict(value, label) {
if (!/^[+-]?\d+$/.test(value)) fail(`${label} must be an integer, got ${JSON.stringify(value)}`);
return Number.parseInt(value, 10);
}
function parseNumberList(value, label, min, max) {
const items = value.split(",").map((v) => v.trim());
if (!items.length || items.some((v) => v === "")) fail(`${label} has an empty value`);
const out = items.map((v) => {
const n = parseIntStrict(v, label);
if (n < min || n > max) fail(`${label}=${n} is outside ${min}..${max}`);
return n;
});
return [...new Set(out)].sort((a, b) => a - b);
}
/**
* UNTIL. RFC 5545 writes it as a UTC stamp (20261101T090000Z) or a bare date;
* Relay schedules also travel as JSON, so a full ISO-8601 string with any
* offset is accepted and resolved to the same instant. UNTIL is an INSTANT, not
* a wall time: it may legitimately be pinned in a different zone from the
* schedule's own, and it means the same moment either way.
*/
function parseUntil(raw) {
const text = raw.trim();
let instantMs = null;
let basic = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(text);
if (basic) {
instantMs = utcMsFromCivil(+basic[1], +basic[2], +basic[3], +basic[4], +basic[5], +basic[6]);
} else if ((basic = /^(\d{4})(\d{2})(\d{2})$/.exec(text))) {
instantMs = utcMsFromCivil(+basic[1], +basic[2], +basic[3]);
} else if (/^\d{4}-\d{2}-\d{2}([T ].+)?$/.test(text)) {
const parsed = Date.parse(text);
if (Number.isNaN(parsed)) fail(`UNTIL=${JSON.stringify(raw)} is not a date`);
instantMs = parsed;
} else if (/^\d{8}T\d{6}$/.test(text)) {
fail("UNTIL must carry a zone — use the RFC UTC form (…Z) or a full ISO-8601 stamp with an offset");
} else {
fail(`UNTIL=${JSON.stringify(raw)} is not a date`);
}
if (Number.isNaN(instantMs)) fail(`UNTIL=${JSON.stringify(raw)} is not a date`);
return { text, instantMs };
}
/**
* Parse the RFC 5545 subset Relay supports. Anything else throws — an ignored
* rule part is a broken promise, and a schedule is a promise.
*
* Supported: FREQ (DAILY|WEEKLY|MONTHLY|YEARLY), INTERVAL, BYDAY (with ordinals
* like 2TU / -1FR), BYMONTHDAY (negatives allowed), BYHOUR, BYMINUTE, COUNT,
* UNTIL. A leading "RRULE:" is tolerated.
*/
export function parseRRule(rrule) {
if (typeof rrule !== "string" || !rrule.trim()) fail("expected a non-empty string");
let text = rrule.trim();
if (/[\r\n]/.test(text)) fail("must be a single line");
if (/^RRULE:/i.test(text)) text = text.slice(6).trim();
if (!text) fail("expected a non-empty string");
const seen = new Map();
for (const segment of text.split(";")) {
const part = segment.trim();
if (!part) continue;
const match = /^([A-Za-z-]+)=(.*)$/.exec(part);
if (!match) fail(`${JSON.stringify(part)} is not NAME=VALUE`);
const name = match[1].toUpperCase();
const value = match[2].trim();
if (seen.has(name)) fail(`${name} appears more than once`);
if (!SUPPORTED_PARTS.has(name)) {
if (KNOWN_UNSUPPORTED.has(name)) fail(`${name} is not supported by Relay schedules`);
fail(`unknown rule part ${name}`);
}
if (value === "") fail(`${name} has an empty value`);
seen.set(name, value);
}
if (!seen.has("FREQ")) fail("FREQ is required");
const freq = seen.get("FREQ").toUpperCase();
if (!FREQS.has(freq)) {
fail(`FREQ=${seen.get("FREQ")} is not supported — use DAILY, WEEKLY, MONTHLY or YEARLY`);
}
let interval = 1;
if (seen.has("INTERVAL")) {
interval = parseIntStrict(seen.get("INTERVAL"), "INTERVAL");
if (interval < 1) fail(`INTERVAL=${interval} must be 1 or more`);
}
let count = null;
if (seen.has("COUNT")) {
count = parseIntStrict(seen.get("COUNT"), "COUNT");
if (count < 1) fail(`COUNT=${count} must be 1 or more`);
}
let until = null;
if (seen.has("UNTIL")) until = parseUntil(seen.get("UNTIL"));
if (count !== null && until) fail("COUNT and UNTIL cannot both be set");
let byday = null;
if (seen.has("BYDAY")) {
byday = seen
.get("BYDAY")
.split(",")
.map((raw) => {
const token = raw.trim().toUpperCase();
const m = /^([+-]?\d{1,2})?([A-Z]{2})$/.exec(token);
if (!m) fail(`BYDAY=${JSON.stringify(raw)} is not a weekday`);
const dayIndex = DAY_CODES.indexOf(m[2]);
if (dayIndex < 0) fail(`BYDAY=${JSON.stringify(raw)} is not a weekday`);
let ordinal = null;
if (m[1] !== undefined) {
ordinal = Number.parseInt(m[1], 10);
if (ordinal === 0) fail("BYDAY ordinal 0 has no meaning");
if (Math.abs(ordinal) > 53) fail(`BYDAY ordinal ${ordinal} is outside ±53`);
if (freq === "DAILY" || freq === "WEEKLY") {
fail(`BYDAY ordinals (${token}) are meaningless with FREQ=${freq}`);
}
}
return { day: m[2], dayIndex, ordinal };
});
const keyed = new Map(byday.map((d) => [`${d.ordinal ?? ""}${d.day}`, d]));
byday = [...keyed.values()];
}
let bymonthday = null;
if (seen.has("BYMONTHDAY")) {
if (freq === "WEEKLY") fail("BYMONTHDAY cannot be used with FREQ=WEEKLY");
bymonthday = parseNumberList(seen.get("BYMONTHDAY"), "BYMONTHDAY", -31, 31);
if (bymonthday.includes(0)) fail("BYMONTHDAY=0 has no meaning");
}
const byhour = seen.has("BYHOUR") ? parseNumberList(seen.get("BYHOUR"), "BYHOUR", 0, 23) : null;
const byminute = seen.has("BYMINUTE") ? parseNumberList(seen.get("BYMINUTE"), "BYMINUTE", 0, 59) : null;
return { freq, interval, byday, bymonthday, byhour, byminute, count, until, source: text };
}
/** Rewrite only COUNT in a rule's text, leaving the author's wording intact. */
function rruleWithCount(rrule, nextCount) {
const text = String(rrule);
if (nextCount === null) return text.replace(/;?\s*COUNT=\d+/i, "");
return text.replace(/(COUNT=)(\d+)/i, `$1${nextCount}`);
}
// ---------------------------------------------------------------------------
// Schedule reading
// ---------------------------------------------------------------------------
function toInstant(value, label = "time") {
if (value == null) return null;
if (value instanceof Date) {
const ms = value.getTime();
if (Number.isNaN(ms)) throw new Error(`schedule: ${label} is an invalid Date`);
return ms;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error(`schedule: ${label} is not a finite timestamp`);
return value;
}
if (typeof value === "string") {
const ms = Date.parse(value);
if (Number.isNaN(ms)) throw new Error(`schedule: ${label} ${JSON.stringify(value)} is not a date`);
return ms;
}
throw new Error(`schedule: ${label} must be an ISO string, Date or epoch ms`);
}
function timezoneOf(schedule) {
const tz = schedule?.timezone;
if (tz) {
zoneFormatter(tz); // validates, throws with a clear message
return tz;
}
// §10.1: the recipient's zone, resolved at each fire. This machine IS the
// recipient's machine, so its zone is the answer when none is pinned.
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
/** RFC's DTSTART stand-in — see THE ANCHOR at the top of this file. */
function anchorInstant(schedule, nowMs) {
return (
toInstant(schedule?.createdAt, "createdAt") ??
toInstant(schedule?.acceptedAt, "acceptedAt") ??
toInstant(schedule?.lastFireAt, "lastFireAt") ??
nowMs
);
}
// ---------------------------------------------------------------------------
// Occurrence expansion
// ---------------------------------------------------------------------------
function dayMatches(rule, civil, anchorCivil) {
const epochDay = epochDayFromCivil(civil.year, civil.month, civil.day);
const weekday = weekdayFromEpochDay(epochDay);
// 1. Is this day inside a period the INTERVAL selects?
if (rule.freq === "DAILY") {
const anchorDay = epochDayFromCivil(anchorCivil.year, anchorCivil.month, anchorCivil.day);
if (mod(epochDay - anchorDay, rule.interval) !== 0) return false;
} else if (rule.freq === "WEEKLY") {
const anchorWeek = weekIndexFromEpochDay(
epochDayFromCivil(anchorCivil.year, anchorCivil.month, anchorCivil.day),
);
if (mod(weekIndexFromEpochDay(epochDay) - anchorWeek, rule.interval) !== 0) return false;
} else if (rule.freq === "MONTHLY") {
const months = (civil.year - anchorCivil.year) * 12 + (civil.month - anchorCivil.month);
if (mod(months, rule.interval) !== 0) return false;
} else if (rule.freq === "YEARLY") {
if (mod(civil.year - anchorCivil.year, rule.interval) !== 0) return false;
}
// 2. Does the day itself match the BY* parts (or the anchor's default day)?
const hasDayFilter = Boolean(rule.byday || rule.bymonthday);
if (rule.bymonthday) {
const dim = daysInMonth(civil.year, civil.month);
const ok = rule.bymonthday.some((d) => (d > 0 ? d === civil.day : dim + d + 1 === civil.day));
if (!ok) return false;
}
if (rule.byday) {
const ok = rule.byday.some((entry) => {
if (entry.dayIndex !== weekday) return false;
if (entry.ordinal === null) return true;
// Ordinals count inside the month for MONTHLY, inside the year for YEARLY
// (RFC 5545's scoping — "-1FR" of a yearly rule is the year's last Friday).
const [startDay, endDay] =
rule.freq === "YEARLY"
? [epochDayFromCivil(civil.year, 1, 1), epochDayFromCivil(civil.year, 12, 31)]
: [
epochDayFromCivil(civil.year, civil.month, 1),
epochDayFromCivil(civil.year, civil.month, daysInMonth(civil.year, civil.month)),
];
return nthWeekdayEpochDay(startDay, endDay, entry.dayIndex, entry.ordinal) === epochDay;
});
if (!ok) return false;
}
if (!hasDayFilter) {
// Nothing said which day, so the anchor says it — RFC's DTSTART default.
// Note the RFC behaviour for impossible dates: a monthly rule anchored on
// the 31st simply has no February occurrence. Clamping to the 28th would
// invent a fire nobody asked for.
if (rule.freq === "WEEKLY") {
const anchorWeekday = weekdayFromEpochDay(
epochDayFromCivil(anchorCivil.year, anchorCivil.month, anchorCivil.day),
);
if (weekday !== anchorWeekday) return false;
} else if (rule.freq === "MONTHLY") {
if (civil.day !== anchorCivil.day) return false;
} else if (rule.freq === "YEARLY") {
if (civil.month !== anchorCivil.month || civil.day !== anchorCivil.day) return false;
}
}
return true;
}
/**
* Fire instants strictly after `fromMs`, ascending, honouring UNTIL and COUNT.
* Yields at most `limit`. Duplicate instants (two wall times folded onto one by
* a DST gap) collapse to one — never double-fire.
*/
function* occurrences(rule, { timeZone, anchorMs, fromMs, limit }) {
const hours = rule.byhour ?? [0];
const minutes = rule.byminute ?? [0];
const anchorCivil = zonedParts(anchorMs, timeZone);
const startCivil = zonedParts(fromMs, timeZone);
let epochDay = epochDayFromCivil(startCivil.year, startCivil.month, startCivil.day);
let emitted = 0;
let lastInstant = -Infinity;
const cap = rule.count === null ? Infinity : rule.count;
for (let step = 0; step < MAX_SCAN_STEPS; step += 1) {
if (emitted >= limit || emitted >= cap) return;
const civil = civilFromEpochDay(epochDay);
// Skip whole ineligible years/months in one step instead of day by day.
if (rule.freq === "YEARLY" && mod(civil.year - anchorCivil.year, rule.interval) !== 0) {
epochDay = epochDayFromCivil(civil.year + 1, 1, 1);
continue;
}
if (rule.freq === "MONTHLY") {
const months = (civil.year - anchorCivil.year) * 12 + (civil.month - anchorCivil.month);
if (mod(months, rule.interval) !== 0) {
epochDay = epochDayFromCivil(
civil.month === 12 ? civil.year + 1 : civil.year,
civil.month === 12 ? 1 : civil.month + 1,
1,
);
continue;
}
}
if (dayMatches(rule, civil, anchorCivil)) {
for (const hour of hours) {
for (const minute of minutes) {
const instant = wallToInstant(
{ year: civil.year, month: civil.month, day: civil.day, hour, minute, second: 0 },
timeZone,
);
if (instant <= fromMs || instant <= lastInstant) continue;
if (rule.until && instant > rule.until.instantMs) return;
lastInstant = instant;
yield instant;
emitted += 1;
if (emitted >= limit || emitted >= cap) return;
}
}
}
epochDay += 1;
}
}
function scheduleOccurrences(schedule, { fromMs, limit, nowMs }) {
const rule = parseRRule(schedule?.rrule);
const timeZone = timezoneOf(schedule);
const anchorMs = anchorInstant(schedule, nowMs);
return [...occurrences(rule, { timeZone, anchorMs, fromMs, limit })];
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* The next fire times, as ISO-8601 strings carrying the schedule's zone offset
* — the accept sheet's "next 3 fire times in the recipient's clock", and the
* echo-back the sending agent self-checks against its intent.
*
* Fires are strictly after `after` (default: now). Dead contracts (declined /
* ended / revoked) have no future and return []. A paused schedule still
* previews — pause is reversible and the UI has to be able to say what resumes.
*/
export function nextFireTimes(schedule, { after, count = 3, now } = {}) {
const nowMs = toInstant(now, "now") ?? Date.now();
const wanted = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
if (!wanted) return [];
if (schedule?.state && DEAD_STATES.has(schedule.state)) return [];
const fromMs = toInstant(after, "after") ?? nowMs;
const timeZone = timezoneOf(schedule);
return scheduleOccurrences(schedule, { fromMs, limit: wanted, nowMs }).map((ms) =>
formatZonedISO(ms, timeZone),
);
}
function timeOfDayPhrase(rule) {
const hours = rule.byhour ?? [0];
const minutes = rule.byminute ?? [0];
const stamps = [];
// Hours run unpadded — "9:00", the way a person says it — except midnight,
// which is written "00:00" because a bare "0:00" reads like a typo. Both
// spellings are §10.1's own ("every weekday · 9:00", "every 2nd of the
// month · 00:00").
for (const h of hours) for (const m of minutes) stamps.push(`${h === 0 ? "00" : h}:${pad2(m)}`);
stamps.sort((a, b) => Number(a.split(":")[0]) - Number(b.split(":")[0]) || a.localeCompare(b));
if (stamps.length === 1) return stamps[0];
if (stamps.length === 2) return `${stamps[0]} and ${stamps[1]}`;
if (stamps.length === 3) return `${stamps[0]}, ${stamps[1]} and ${stamps[2]}`;
return `${stamps.length} times a day`;
}
function ordinalWord(n) {
const abs = Math.abs(n);
const suffix =
abs % 100 >= 11 && abs % 100 <= 13
? "th"
: abs % 10 === 1
? "st"
: abs % 10 === 2
? "nd"
: abs % 10 === 3
? "rd"
: "th";
return `${abs}${suffix}`;
}
function monthdayWord(n) {
if (n === -1) return "last day";
if (n < 0) return `${ordinalWord(n)} last day`;
return ordinalWord(n);
}
function bydayOrdinalWord(n) {
if (n === -1) return "last";
if (n < 0) return `${ordinalWord(n)} last`;
return ordinalWord(n);
}
function listPhrase(items) {
if (items.length === 1) return items[0];
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
}
const WEEKDAY_SET = "MO,TU,WE,TH,FR";
function cadencePhrase(rule, anchorCivil) {
const every = (n, unit) => (n === 1 ? `every ${unit}` : n === 2 ? `every other ${unit}` : `every ${n} ${unit}s`);
if (rule.freq === "DAILY") {
if (rule.byday || rule.bymonthday) return null; // a filtered daily rule is not phraseable
return every(rule.interval, "day");
}
if (rule.freq === "WEEKLY") {
const days = rule.byday
? rule.byday.map((d) => d.dayIndex)
: [weekdayFromEpochDay(epochDayFromCivil(anchorCivil.year, anchorCivil.month, anchorCivil.day))];
const codes = [...days].sort((a, b) => a - b).map((i) => DAY_CODES[i]);
if (codes.length === 7) return every(rule.interval, "day");
const isWeekdays = codes.slice().sort().join(",") === WEEKDAY_SET.split(",").sort().join(",");
if (isWeekdays && rule.interval === 1) return "every weekday";
const names = [...days].sort((a, b) => mod(a - 1, 7) - mod(b - 1, 7)).map((i) => DAY_NAMES[i]);
if (rule.interval === 1) return `every ${listPhrase(names)}`;
if (rule.interval === 2 && names.length === 1) return `every other ${names[0]}`;
return `every ${rule.interval} weeks on ${listPhrase(names)}`;
}
if (rule.freq === "MONTHLY") {
let what = null;
if (rule.bymonthday && !rule.byday) {
what = listPhrase(rule.bymonthday.map(monthdayWord));
} else if (rule.byday && !rule.bymonthday) {
if (rule.byday.some((d) => d.ordinal === null)) return null; // "every tuesday of the month" is a weekly rule in disguise
what = listPhrase(rule.byday.map((d) => `${bydayOrdinalWord(d.ordinal)} ${DAY_NAMES[d.dayIndex]}`));
} else if (!rule.byday && !rule.bymonthday) {
what = monthdayWord(anchorCivil.day);
} else {
return null; // BYDAY and BYMONTHDAY together — say the truth instead
}
if (rule.interval === 1) return `every ${what} of the month`;
if (rule.interval === 2) return `every other month on the ${what}`;
return `every ${rule.interval} months on the ${what}`;
}
if (rule.freq === "YEARLY") {
if (rule.byday) return null;
const day = rule.bymonthday && rule.bymonthday.length === 1 ? rule.bymonthday[0] : anchorCivil.day;
if (day < 0) return null;
const when = `${day} ${MONTH_NAMES[anchorCivil.month - 1]}`;
if (rule.interval === 1) return `every year on ${when}`;
return `every ${rule.interval} years on ${when}`;
}
return null;
}
/** "thu 21 aug" — the short date the fallback phrase leans on. */
function shortDatePhrase(instantMs, timeZone) {
const p = zonedParts(instantMs, timeZone);
const weekday = weekdayFromEpochDay(epochDayFromCivil(p.year, p.month, p.day));
return `${DAY_SHORT[weekday]} ${p.day} ${MONTH_SHORT[p.month - 1]}`;
}
/** The last segment of an IANA zone, humanised: Europe/Berlin → Berlin. */
function zoneLabel(timeZone) {
const tail = String(timeZone).split("/").pop() || String(timeZone);
return tail.replace(/_/g, " ");
}
/**
* The calm human phrase for a cadence — never RRULE, never cron-speak.
* "every weekday · 9:00", "every monday · 9:00", "every 2nd of the month · 0:00"
* Too baroque to phrase? Render the truth instead: "recurring · next thu 21 aug · 9:00".
*
* Pass `localTimezone` (the reader's own zone) and a foreign pinned zone is
* appended — "· 9:00 Berlin" — exactly as §10.1 requires and never otherwise.
*/
export function describeCadence(schedule, { localTimezone, now } = {}) {
const nowMs = toInstant(now, "now") ?? Date.now();
const rule = parseRRule(schedule?.rrule);
const timeZone = timezoneOf(schedule);
const anchorCivil = zonedParts(anchorInstant(schedule, nowMs), timeZone);
const zoneSuffix = localTimezone && localTimezone !== timeZone ? ` ${zoneLabel(timeZone)}` : "";
const phrase = cadencePhrase(rule, anchorCivil);
if (phrase) return `${phrase} · ${timeOfDayPhrase(rule)}${zoneSuffix}`;
const [next] = nextFireTimes(schedule, { now: nowMs, count: 1 });
if (!next) return "recurring";
const instant = Date.parse(next);
return `recurring · next ${shortDatePhrase(instant, timeZone)} · ${timeOfDayPhrase(rule)}${zoneSuffix}`;
}
/**
* "in 5d" / "in 3h" / "in 12m" — the countdown beside a next-fire time.
* Floors, so it never over-promises; anything due, overdue, or under a minute
* away reads "now".
*/
export function describeCountdown(nextISO, now) {
const target = toInstant(nextISO, "next fire");
if (target === null) return "now";
const nowMs = toInstant(now, "now") ?? Date.now();
const delta = target - nowMs;
if (delta < MINUTE_MS) return "now";
if (delta < HOUR_MS) return `in ${Math.floor(delta / MINUTE_MS)}m`;
if (delta < DAY_MS) return `in ${Math.floor(delta / HOUR_MS)}h`;
return `in ${Math.floor(delta / DAY_MS)}d`;
}
/**
* The run id — hash(scheduleId, scheduledFireTime) — and the whole
* exactly-once mechanism (§10.2). The fire time is normalised to its UTC
* instant first, so the same moment written in two zones (or with a different
* offset spelling) is the same run, never two.
*/
export function runIdFor(scheduleId, scheduledFireTimeISO) {
const id = String(scheduleId ?? "").trim();
if (!id) throw new Error("schedule: runIdFor needs a scheduleId");
const instant = toInstant(scheduledFireTimeISO, "scheduled fire time");
if (instant === null) throw new Error("schedule: runIdFor needs a scheduled fire time");
const canonical = new Date(instant).toISOString();
const digest = createHash("sha256").update(`${id}