Two new tables — event_action_settings (the deployment switchboard) and event_run_budget (what a run has spent and the most it may) — plus verified_at and verified_by on event_versions. The whole authorisation decision moves behind one function, events/authorize.js: role, enablement, cap, and the shard's own switch named as the layer core deliberately does not duplicate. Three routes, none moved: GET/PUT /admin/events/actions (admin in both directions) and POST /admin/events/:id/verify (admin, editor — a dry run dispatches nothing). Four decisions, settled by the org lead 2026-09-03: - The default-off line falls between inspect and change, not between notify and inspect. Read literally, §K shipped core.wait disabled. The same line is the role floor. - The tightest cap wins where two actions spend one dimension, pinned into the run at creation with the action it came from. - A refusal follows the step's on_failure and takes health to degraded — its own status and its own log kind, because a refusal is not an outage. - The verify gate is enforced for scheduled starts only: a human pressing Start now is the review the gate exists to require. Derived and flagged for review: a dry run fails rather than warns on a disabled action or an over-cap plan, and the unattended path does not re-check the starter's role. +111 tests (1921/1847/73/1 — the one failure pre-existing and environmental), including a 403 walk over the real router and two concurrent spends against one cap on a real MariaDB. The live walk found two defects, both fixed here: the run console route dropped the budget it was handed, and the role refusal used a plural verb over a one-item list. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
412 lines
17 KiB
JavaScript
412 lines
17 KiB
JavaScript
// ── Expansion and the calendar (EVENTS_PLAN.md Phase 4) ────────────────────
|
|
//
|
|
// The phase's shipped claim: **a published definition with a recurrence produces
|
|
// occurrences on its own, and the calendar shows the ones that exist beside the
|
|
// ones that will.** The arithmetic underneath is proved separately in
|
|
// `eventRecurrence.test.js`; this file is about the two decisions the org lead
|
|
// took on 2026-09-02 and the properties they imply:
|
|
//
|
|
// • occurrences become REAL ROWS inside a fourteen-day horizon, and beyond it
|
|
// the calendar projects rather than materialising
|
|
// • a projection is never emitted for an instant a run already occupies — so
|
|
// the fortnight inside the horizon is not drawn twice, and a CANCELLED
|
|
// occurrence does not come back as a forecast
|
|
// • expansion looks forward from `now - grace` only, so an occurrence nobody
|
|
// could ever have seen is not invented retroactively
|
|
// • only `ready` definitions expand: publishing IS the schedule switch (§E)
|
|
// and archiving is how an operator turns one off
|
|
// • expansion is idempotent, because it runs every fifteen seconds for ever
|
|
//
|
|
// Stubbed at the `.db` layer, the shape `eventRunner.test.js` uses.
|
|
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const registries = require('../src/modules/registries')
|
|
const runner = require('../src/utils/eventRunner')
|
|
const calendarModel = require('../src/model/events/eventCalendar.model')
|
|
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
|
const runsDb = require('../src/model/events/eventRuns.db')
|
|
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
|
const logDb = require('../src/model/events/eventRunLog.db')
|
|
// Phase 6: `runsModel.create` prices the version against the switchboard and
|
|
// seeds the run's budget, so expansion now reaches two more tables. Unstubbed
|
|
// they are a ten-second ECONNREFUSED per occurrence.
|
|
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
|
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
|
const versionsDb = require('../src/model/events/eventVersions.db')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
// A Tuesday. Chosen so a "friday" schedule has its first occurrence three days
|
|
// out — inside the horizon, but not today, which is what keeps "materialised"
|
|
// and "due" from being confusable in these fixtures.
|
|
const NOW = new Date('2026-09-01T12:00:00Z')
|
|
|
|
const SPEC = {
|
|
schedule: { kind: 'weekly', days: ['friday'], time: '20:00' },
|
|
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
|
}
|
|
|
|
let store
|
|
const originals = {}
|
|
for (const [name, mod] of [
|
|
['definitionsDb', definitionsDb],
|
|
['runsDb', runsDb],
|
|
['stepsDb', stepsDb],
|
|
['logDb', logDb],
|
|
['versionsDb', versionsDb],
|
|
['settingsDb', settingsDb],
|
|
['budgetDb', budgetDb],
|
|
]) {
|
|
originals[name] = { mod, fns: { ...mod } }
|
|
}
|
|
|
|
const restoreOriginals = () => {
|
|
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
|
|
}
|
|
|
|
const clone = (o) => JSON.parse(JSON.stringify(o))
|
|
|
|
/** One `ready` definition with a published version carrying `spec`. */
|
|
function addDefinition(id, overrides = {}) {
|
|
const definition = {
|
|
id,
|
|
title: `Event ${id}`,
|
|
slug: `event-${id}`,
|
|
state: 'ready',
|
|
timezone: 'UTC',
|
|
grace_seconds: 900,
|
|
concurrency_key: null,
|
|
current_version_id: id * 100,
|
|
series_id: null,
|
|
series_name: null,
|
|
series_slug: null,
|
|
spec: clone(SPEC),
|
|
...overrides,
|
|
}
|
|
store.definitions.set(id, definition)
|
|
store.versions.set(definition.current_version_id, {
|
|
id: definition.current_version_id,
|
|
definition_id: id,
|
|
version: 1,
|
|
spec: definition.spec,
|
|
// Verified by default (Phase 6). §K holds a scheduled occurrence of a version
|
|
// nobody has dry-run, so an unverified fixture would make every test in this
|
|
// file assert nothing about recurrence and everything about that one gate.
|
|
// The gate has its own test below, where an occurrence is what is being
|
|
// measured rather than what is in the way.
|
|
verified_at: new Date('2026-08-01T00:00:00Z'),
|
|
verified_by: 1,
|
|
...(overrides.version || {}),
|
|
})
|
|
return definition
|
|
}
|
|
|
|
function installStubs() {
|
|
store = { definitions: new Map(), versions: new Map(), runs: [], steps: [], log: [], nextRunId: 1 }
|
|
|
|
Object.assign(definitionsDb, {
|
|
findSchedulable: async () =>
|
|
[...store.definitions.values()]
|
|
.filter((d) => d.state === 'ready' && d.current_version_id)
|
|
.map((d) => ({ ...d, version_spec: store.versions.get(d.current_version_id)?.spec || null })),
|
|
getById: async (id) => store.definitions.get(id) || null,
|
|
list: async () => [...store.definitions.values()],
|
|
})
|
|
|
|
Object.assign(versionsDb, { getById: async (id) => store.versions.get(id) || null })
|
|
|
|
Object.assign(runsDb, {
|
|
materialise: async (run) => {
|
|
const at = new Date(run.scheduled_for).getTime()
|
|
// The unique index, in memory: one row per (definition, scope, instant).
|
|
const clash = store.runs.find(
|
|
(r) => r.definition_id === run.definition_id && r.scope === (run.scope || '') && new Date(r.scheduled_for).getTime() === at,
|
|
)
|
|
if (clash) return null
|
|
const id = store.nextRunId++
|
|
const definition = store.definitions.get(run.definition_id)
|
|
store.runs.push({
|
|
...run,
|
|
id,
|
|
scope: run.scope || '',
|
|
status: 'scheduled',
|
|
health: 'ok',
|
|
waiting_steps: 0,
|
|
definition_title: definition?.title,
|
|
definition_slug: definition?.slug,
|
|
series_id: definition?.series_id ?? null,
|
|
series_name: definition?.series_name ?? null,
|
|
series_slug: definition?.series_slug ?? null,
|
|
version_number: 1,
|
|
})
|
|
return id
|
|
},
|
|
getById: async (id) => store.runs.find((r) => r.id === id) || null,
|
|
findOccurrence: async (definitionId, scope, at) =>
|
|
store.runs.find(
|
|
(r) => r.definition_id === definitionId && r.scope === (scope || '') && new Date(r.scheduled_for).getTime() === new Date(at).getTime(),
|
|
) || null,
|
|
listInWindow: async ({ from, to, status = null, scope = null, seriesId = null }) =>
|
|
store.runs
|
|
.filter((r) => {
|
|
const at = new Date(r.scheduled_for).getTime()
|
|
if (at < new Date(from).getTime() || at >= new Date(to).getTime()) return false
|
|
if (status && r.status !== status) return false
|
|
if (scope !== null && scope !== undefined && r.scope !== scope) return false
|
|
if (seriesId && Number(r.series_id) !== Number(seriesId)) return false
|
|
return true
|
|
})
|
|
.sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for)),
|
|
})
|
|
|
|
Object.assign(stepsDb, { materialisePhase: async () => [] })
|
|
Object.assign(logDb, { write: async (line) => { store.log.push(line); return 1 } })
|
|
|
|
// Phase 6. No stored switch anywhere in this file: an empty switchboard is a
|
|
// fresh deployment, and expansion is not what this file is measuring.
|
|
Object.assign(settingsDb, { byIds: async () => new Map(), get: async () => null })
|
|
Object.assign(budgetDb, { seed: async () => 0, forRun: async () => [] })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
registries._reset()
|
|
registries.registerCore()
|
|
installStubs()
|
|
})
|
|
|
|
afterEach(restoreOriginals)
|
|
|
|
const instants = () => store.runs.map((r) => new Date(r.scheduled_for).toISOString()).sort()
|
|
|
|
// ── Expansion ──────────────────────────────────────────────────────────────
|
|
|
|
test('a weekly definition materialises exactly the occurrences inside the horizon', async () => {
|
|
addDefinition(1)
|
|
const created = await runner.expandSchedules(NOW)
|
|
|
|
// 1 September 2026 is a Tuesday. Fridays inside 14 days: the 4th and the 11th.
|
|
assert.equal(created, 2)
|
|
assert.deepEqual(instants(), ['2026-09-04T20:00:00.000Z', '2026-09-11T20:00:00.000Z'])
|
|
})
|
|
|
|
test('expansion is idempotent — running it again creates nothing', async () => {
|
|
// The property the whole design leans on: this runs every fifteen seconds for
|
|
// ever. `INSERT IGNORE` against the occurrence key is what makes that free,
|
|
// and a second call that created rows would be a duplicate event, not a
|
|
// duplicate row.
|
|
addDefinition(1)
|
|
assert.equal(await runner.expandSchedules(NOW), 2)
|
|
assert.equal(await runner.expandSchedules(NOW), 0)
|
|
assert.equal(await runner.expandSchedules(new Date(NOW.getTime() + 60_000)), 0)
|
|
assert.equal(store.runs.length, 2)
|
|
})
|
|
|
|
test('only `ready` definitions expand — publishing is the switch, archiving turns it off', async () => {
|
|
addDefinition(1, { state: 'draft' })
|
|
addDefinition(2, { state: 'archived' })
|
|
addDefinition(3, { state: 'ready' })
|
|
await runner.expandSchedules(NOW)
|
|
assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [3])
|
|
})
|
|
|
|
test('a draft edit cannot materialise anything — the VERSION spec is what expands', async () => {
|
|
// The definition's working copy says daily; the published version says weekly.
|
|
// A half-typed recurrence an author is midway through must never produce a run.
|
|
const definition = addDefinition(1)
|
|
definition.spec = {
|
|
schedule: { kind: 'weekly', days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], time: '20:00' },
|
|
phases: SPEC.phases,
|
|
}
|
|
await runner.expandSchedules(NOW)
|
|
assert.equal(store.runs.length, 2)
|
|
})
|
|
|
|
test('a manual definition expands to nothing at all', async () => {
|
|
addDefinition(1, { spec: { schedule: { kind: 'manual' }, phases: SPEC.phases } })
|
|
store.versions.get(100).spec = store.definitions.get(1).spec
|
|
assert.equal(await runner.expandSchedules(NOW), 0)
|
|
assert.equal(store.runs.length, 0)
|
|
})
|
|
|
|
test('an occurrence older than the grace window is never materialised at all', async () => {
|
|
// Not materialised-then-swept. A row nobody could ever have seen or cancelled
|
|
// is not history, and writing one would put a `missed` event on the calendar
|
|
// for a date on which this deployment had no such event. The horizon is what
|
|
// makes the missed sweep meaningful instead: a real outage finds rows already
|
|
// there, because they were written a fortnight early.
|
|
addDefinition(1, { grace_seconds: 900 })
|
|
// A Monday, three days after the Friday occurrence — far outside the grace.
|
|
await runner.expandSchedules(new Date('2026-09-07T12:00:00Z'))
|
|
assert.ok(!instants().includes('2026-09-04T20:00:00.000Z'))
|
|
})
|
|
|
|
test('an occurrence still inside the grace window IS materialised', async () => {
|
|
// The case this rule exists for: a definition published four minutes before
|
|
// its own first occurrence. `now - grace` is the window start, so the
|
|
// occurrence that has only just passed is still created and still startable.
|
|
addDefinition(1, { grace_seconds: 3600 })
|
|
await runner.expandSchedules(new Date('2026-09-04T20:10:00Z'))
|
|
assert.ok(instants().includes('2026-09-04T20:00:00.000Z'))
|
|
})
|
|
|
|
test('a DST-adjusted occurrence records WHY its clock reads oddly', async () => {
|
|
// Discovering daylight saving at 3am on the last Sunday in October is the
|
|
// failure this line exists to prevent.
|
|
addDefinition(1, {
|
|
timezone: 'Europe/Berlin',
|
|
spec: { schedule: { kind: 'weekly', days: ['sunday'], time: '02:30' }, phases: SPEC.phases },
|
|
})
|
|
store.versions.get(100).spec = store.definitions.get(1).spec
|
|
|
|
await runner.expandSchedules(new Date('2026-03-22T12:00:00Z'))
|
|
const adjusted = store.log.find((l) => l.detail?.dstAdjusted)
|
|
assert.equal(adjusted.detail.dstAdjusted, 'gap')
|
|
assert.equal(adjusted.detail.timezone, 'Europe/Berlin')
|
|
assert.ok(instants().includes('2026-03-29T01:00:00.000Z'))
|
|
})
|
|
|
|
test('a definition whose spec is nonsense is skipped, and the sweep carries on', async () => {
|
|
// A spec written straight into the database with a shape the validator would
|
|
// have refused is a bad row, not a bad tick.
|
|
addDefinition(1, { spec: { schedule: { kind: 'weekly', days: ['froday'], time: '20:00' }, phases: SPEC.phases } })
|
|
store.versions.get(100).spec = store.definitions.get(1).spec
|
|
addDefinition(2)
|
|
|
|
const created = await runner.expandSchedules(NOW)
|
|
assert.equal(created, 2)
|
|
assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [2])
|
|
})
|
|
|
|
test('every materialised occurrence is marked as coming from the schedule', async () => {
|
|
// `started_by` is NULL for a scheduled occurrence and for one an admin started
|
|
// whose account has since gone, so the log is the only place the two are told
|
|
// apart.
|
|
addDefinition(1)
|
|
await runner.expandSchedules(NOW)
|
|
const created = store.log.filter((l) => l.kind === 'run.created' && l.detail?.source)
|
|
assert.equal(created.length, 2)
|
|
for (const line of created) {
|
|
assert.equal(line.detail.source, 'schedule')
|
|
assert.equal(line.detail.by, null)
|
|
}
|
|
})
|
|
|
|
// ── The calendar ───────────────────────────────────────────────────────────
|
|
|
|
test('inside the horizon the calendar shows runs; beyond it, projections', async () => {
|
|
addDefinition(1)
|
|
await runner.expandSchedules(NOW)
|
|
|
|
const result = await calendarModel.calendar({
|
|
from: new Date('2026-09-01T00:00:00Z'),
|
|
to: new Date('2026-10-01T00:00:00Z'),
|
|
now: NOW,
|
|
})
|
|
|
|
const kinds = result.entries.map((e) => `${e.kind} ${new Date(e.scheduledFor).toISOString().slice(0, 10)}`)
|
|
assert.deepEqual(kinds, [
|
|
'run 2026-09-04',
|
|
'run 2026-09-11',
|
|
'projected 2026-09-18',
|
|
'projected 2026-09-25',
|
|
])
|
|
// The forecast is arithmetic and says so: no row, nothing to open.
|
|
for (const entry of result.entries.filter((e) => e.kind === 'projected')) {
|
|
assert.equal(entry.runId, null)
|
|
assert.equal(entry.status, null)
|
|
}
|
|
})
|
|
|
|
test('a projection is never drawn over an instant a run already occupies', async () => {
|
|
addDefinition(1)
|
|
await runner.expandSchedules(NOW)
|
|
const result = await calendarModel.calendar({
|
|
from: new Date('2026-09-01T00:00:00Z'),
|
|
to: new Date('2026-09-15T00:00:00Z'),
|
|
now: NOW,
|
|
})
|
|
assert.equal(result.entries.length, 2)
|
|
assert.ok(result.entries.every((e) => e.kind === 'run'))
|
|
})
|
|
|
|
test('a CANCELLED occurrence does not come back as a forecast', async () => {
|
|
// The same rule, and the case it earns its keep on. An operator who called an
|
|
// event off must not find it on the calendar again ten seconds later looking
|
|
// like it is still coming.
|
|
addDefinition(1)
|
|
await runner.expandSchedules(NOW)
|
|
store.runs[0].status = 'cancelled'
|
|
|
|
const result = await calendarModel.calendar({
|
|
from: new Date('2026-09-01T00:00:00Z'),
|
|
to: new Date('2026-09-15T00:00:00Z'),
|
|
now: NOW,
|
|
})
|
|
const onTheDay = result.entries.filter((e) => new Date(e.scheduledFor).toISOString().startsWith('2026-09-04'))
|
|
assert.equal(onTheDay.length, 1)
|
|
assert.equal(onTheDay[0].kind, 'run')
|
|
assert.equal(onTheDay[0].status, 'cancelled')
|
|
})
|
|
|
|
test('a status filter suppresses projections, because a forecast has no status', async () => {
|
|
addDefinition(1)
|
|
await runner.expandSchedules(NOW)
|
|
const result = await calendarModel.calendar({
|
|
from: new Date('2026-09-01T00:00:00Z'),
|
|
to: new Date('2026-10-01T00:00:00Z'),
|
|
status: 'scheduled',
|
|
now: NOW,
|
|
})
|
|
assert.ok(result.entries.every((e) => e.kind === 'run'))
|
|
assert.equal(result.entries.length, 2)
|
|
})
|
|
|
|
test('a series filter narrows runs and projections alike', async () => {
|
|
addDefinition(1, { series_id: 7, series_name: 'Royal Spy Mission' })
|
|
addDefinition(2, { series_id: 9, series_name: 'Something Else' })
|
|
await runner.expandSchedules(NOW)
|
|
|
|
const result = await calendarModel.calendar({
|
|
from: new Date('2026-09-01T00:00:00Z'),
|
|
to: new Date('2026-10-01T00:00:00Z'),
|
|
seriesId: 7,
|
|
now: NOW,
|
|
})
|
|
assert.ok(result.entries.length > 2)
|
|
assert.ok(result.entries.every((e) => e.seriesName === 'Royal Spy Mission'))
|
|
assert.ok(result.entries.some((e) => e.kind === 'projected'))
|
|
})
|
|
|
|
test('the window is bounded, inverted windows are refused, and the horizon is reported', async () => {
|
|
const wide = await calendarModel.calendar({
|
|
from: new Date('2026-01-01T00:00:00Z'),
|
|
to: new Date('2027-01-01T00:00:00Z'),
|
|
now: NOW,
|
|
})
|
|
assert.equal(wide.ok, false)
|
|
assert.equal(wide.status, 400)
|
|
assert.match(wide.errors.join(' '), /at most 92 days/)
|
|
|
|
const inverted = await calendarModel.calendar({
|
|
from: new Date('2026-09-10T00:00:00Z'),
|
|
to: new Date('2026-09-01T00:00:00Z'),
|
|
now: NOW,
|
|
})
|
|
assert.equal(inverted.ok, false)
|
|
|
|
const fine = await calendarModel.calendar({
|
|
from: new Date('2026-09-01T00:00:00Z'),
|
|
to: new Date('2026-09-15T00:00:00Z'),
|
|
horizonDays: 14,
|
|
now: NOW,
|
|
})
|
|
assert.equal(fine.ok, true)
|
|
assert.equal(fine.horizon.toISOString(), '2026-09-15T12:00:00.000Z')
|
|
})
|