feat(events): schedule, recurrence and the calendar (Phase 4)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s

The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.

No schema change: Phase 1 built every column this needed.

- events/recurrence.js is the ONE place an occurrence is computed, so the
  runner's expansion and the calendar's forecast cannot disagree. No date
  library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
  window starts at `now - grace`, so an occurrence nobody could have seen is
  never invented retroactively; the horizon is what makes the missed sweep
  mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
  re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
  occurrence does not reappear as a forecast.

54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.

Docs: RunicGateway/docs#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:10:16 -05:00
parent a481248bc0
commit 6e73660b52
30 changed files with 3722 additions and 77 deletions

View File

@@ -12,18 +12,24 @@
// one that decides. A spec arriving by any other route (a restore, a fixture, a
// module shipping a definition as content) gets the same answer.
//
// **What Phase 1 knows, and what it deliberately refuses.** Two top-level keys
// exist today: `schedule` and `phases`. `schedule` accepts only `{ kind:
// 'manual' }`, because Phase 4 is what computes an occurrence from a recurrence
// in an IANA zone and a spec that could name `weekly` before then would be a
// schedule nothing honours. Unknown top-level keys are REFUSED rather than
// preserved: a spec that silently carries `announcements` today is a spec whose
// author believes announcements work, and the later phase that gives the key
// meaning would inherit a corpus of unvalidated ones. The refusal list is the
// changelog — Phase 4 adds the recurrence shapes, Phase 5 adds a phase's
// **What this file knows, and what it deliberately refuses.** Two top-level keys
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
// than preserved: a spec that silently carries `announcements` today is a spec
// whose author believes announcements work, and the later phase that gives the
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
// changelog — Phase 4 added the recurrence shapes, Phase 5 adds a phase's
// `advance`, Phase 10 adds `announcements`.
//
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
// arithmetic they describe lives in `events/recurrence.js`, and the zone they are
// computed in is `event_definitions.timezone`, a sibling column this file cannot
// see and does not need to: a well-formed wall clock resolves in every zone (a
// DST gap shifts it, it is never rejected), so a schedule that validates here
// computes there.
const registries = require('../modules/registries')
const recurrence = require('./recurrence')
const { checkLiteral } = require('../engagement/conditions')
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
@@ -39,11 +45,20 @@ const MAX_PHASES = 40
const MAX_STEPS_PER_PHASE = 100
const MAX_STEPS = 500
// The schedule shapes this phase understands. Phase 4 replaces this list with
// the four closed shapes of §E — `once`, `weekly`, `monthly`, `manual` — and
// their timezone arithmetic. It is a list of one rather than an implicit default
// so that the widening is a diff on this line.
const SCHEDULE_KINDS = ['manual']
// The four closed shapes of §E. `manual` is first because it is the default and
// what an unscheduled draft carries; the other three are recurrences the runner
// expands into occurrences ahead of time.
const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly']
// The keys each shape may carry, and the ONLY ones. A `weekly` that also names
// an `at` is an author who believes something about it that is not true — the
// same argument the top-level refusal makes, one level down.
const SCHEDULE_KEYS = {
manual: [],
once: ['at'],
weekly: ['days', 'time'],
monthly: ['nth', 'weekday', 'time'],
}
// What a step does when its attempts are exhausted (§L). The disposition only —
// retry is not one of the values, it is what happens BEFORE one of them. Each
@@ -63,6 +78,86 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
/** The default disposition for an action whose risk class core knows. */
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
/**
* Check one schedule shape and answer the normalised form of it.
*
* Always answers a valid schedule — `{ kind: 'manual' }` when the input was not
* one — because `validate` collects every error and carries on, and a caller
* reading `spec.schedule.days` of a refused spec should find an empty recurrence
* rather than a half-built one.
*
* **`days` is normalised into week order**, not into the order they were typed.
* The spec is compared, described and diffed, and `['friday','monday']` and
* `['monday','friday']` naming the same schedule while differing as JSON is a
* version history that reports edits nobody made.
*/
function validateSchedule(kind, raw, errors) {
const at = (key) => `spec.schedule.${key}`
if (kind === 'once') {
const m = recurrence.AT_RE.exec(String(raw.at ?? ''))
if (!m) {
errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`)
return { kind: 'manual' }
}
const [, y, mo, d, h, mi] = m.map(Number)
// The regex admits `2026-02-30`, which is a string and not a day.
if (!recurrence.isRealDate(y, mo, d)) {
errors.push(`${at('at')}: "${raw.at}" is not a real date`)
return { kind: 'manual' }
}
// Stored as the operator wrote it — a wall clock in the definition's own
// zone, never a UTC instant. §E: the schedule belongs to the event, and the
// instant is derived at materialisation.
const pad = (n) => String(n).padStart(2, '0')
return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` }
}
if (kind === 'weekly' || kind === 'monthly') {
const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null
if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`)
if (kind === 'weekly') {
const rawDays = Array.isArray(raw.days) ? raw.days : null
if (!rawDays || rawDays.length === 0) {
errors.push(`${at('days')}: expected a non-empty array of weekday names`)
return { kind: 'manual' }
}
const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase()))
if (unknown.length) {
errors.push(
`${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`,
)
}
const days = recurrence.WEEKDAYS.filter((name) =>
rawDays.some((d) => String(d).toLowerCase() === name),
)
if (!time || !days.length) return { kind: 'manual' }
return { kind: 'weekly', days, time }
}
const weekday = String(raw.weekday ?? '').toLowerCase()
if (!recurrence.WEEKDAYS.includes(weekday)) {
errors.push(
`${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`,
)
}
const nth = Number(raw.nth)
if (!recurrence.MONTHLY_NTH.includes(nth)) {
// -1 is "last", which a month with five Fridays makes different from 4.
// There is no 5: every month has a first through fourth of every weekday,
// so the closed set has no absent case (org lead, 2026-09-02).
errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`)
}
if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) {
return { kind: 'manual' }
}
return { kind: 'monthly', nth, weekday, time }
}
return { kind: 'manual' }
}
/**
* Check one authored param object against an action's declared params.
*
@@ -136,18 +231,21 @@ function validate(raw, { knownActionIds = [] } = {}) {
}
// ── schedule ──
const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
const rawSchedule =
raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
let schedule = { kind: 'manual' }
if (!isPlainObject(rawSchedule)) {
errors.push('spec.schedule: expected an object')
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
errors.push(
`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')} (recurrence arrives in Phase 4)`,
)
errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`)
} else {
const extra = Object.keys(rawSchedule).filter((k) => k !== 'kind')
if (extra.length) errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')}`)
schedule = { kind: rawSchedule.kind }
const kind = rawSchedule.kind
const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]])
const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k))
if (extra.length) {
errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`)
}
schedule = validateSchedule(kind, rawSchedule, errors)
}
// ── phases ──