Phase 11a declared 24 triggers and stopped where the plan said it would. Standing
11b up found that the next sentence — "24 rules, all enabled = 0; bespoke template
bodies" — described work with no mechanism to land in: templateSeeds.js and
coreRules.js are core files with core arrays in them, and there was no
registerTemplates or registerRules anywhere in registries.js.
So a module could say what an event's payload was and could never say what the
mail should read like. That is tolerable for one trigger and not for a catalogue,
and it is decisive once the bodies carry domain prose core must not contain (§5.2).
Adds api.registerEngagementSeeds({ templates, ruleGroups }) — MODULE_API 1.9.0.
The module supplies data; core keeps seedOne's customized skip, its seed_version
comparison and the block registry's validation, which is the whole argument for a
registry over the ctx.query a module already holds: a copy of any of those living
outside engagement/ would drift the first time core improved the original, and the
drift would surface as a mail somebody already received.
The two halves behave differently, deliberately:
- Templates re-ensure on every boot, so a bumped seedVersion reaches every
deployment except the ones where an operator edited that row.
- Rule groups are ONE-SHOT, each under its own settings guard — re-ensuring
would resurrect a rule an operator deleted and reset one they enabled. This is
11a's seed-key finding stated as an API rather than as a warning: a rule
appended to an existing group reaches fresh installs only, and one that must
reach stamped deployments takes a new group key.
Three prohibitions, each a shipped mistake that would only surface as mail: a
seeded rule is always enabled = 0 (Q3's invariant, ignored rather than refused so
a typo cannot take a module offline at boot); a module may not mark a template
protected; and a rule may only name its own trigger ids and its own or core's
template keys, with template keys namespaced because the key column is UNIQUE.
Runs from modules/lifecycle.js boot() rather than seedDefaults(), and that is
forced rather than chosen: server.js seeds before it requires app.js, and
requiring app.js is what runs the loader — at the moment core seeds, no module has
registered anything. Placed after the installed_modules reconcile (so a disabled
or failed module is skipped) and before the onBoot dispatch (so a module warming a
cache may assume its rules exist).
16 new tests; 1549 core tests green; check:modules clean.
Refs docs#/ENGAGEMENT.md Phase 11b decision 7.
Co-Authored-By: Claude <noreply@anthropic.com>
349 lines
16 KiB
JavaScript
349 lines
16 KiB
JavaScript
// ── Module lifecycle dispatch ──────────────────────────────────────────────
|
|
//
|
|
// Phase 2, PR 5 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract:
|
|
// docs/website/MODULE_API.md §2.5 (the hooks and when they run) and §4.4
|
|
// (failure is a state), plus MODULE_SYSTEM.md §2.4 (what a boot does to
|
|
// `installed_modules`).
|
|
//
|
|
// This is the database half of the loader, and it is a separate file for the
|
|
// same reason modules/schema.js is: `scripts/routeManifest.js` and
|
|
// `swagger/swagger.js` both require app.js with the pool pointed at a dead port,
|
|
// so loader.js may not reach the database. Everything here runs from server.js,
|
|
// after ensureSchema() has proved the database is up.
|
|
//
|
|
// The two halves meet at exactly one place — `loader.setState()` — so the
|
|
// in-memory record that the §4.5 dispatch guard reads and the row the admin
|
|
// panel reads are moved together and cannot disagree.
|
|
//
|
|
// Two properties this file exists to keep:
|
|
//
|
|
// 1. **A module's boot failure costs that module and nothing else** (§4.4).
|
|
// Its routes stay mounted and answer 503; the site comes up; the next
|
|
// module boots as if nothing happened.
|
|
// 2. **The row is a record of what happened, never the source of truth for
|
|
// what is mounted** (§2.4). Nothing here mounts, unmounts or re-scans. It
|
|
// reads the outcome of a scan that already happened and writes it down.
|
|
|
|
const log = require('../utils/logger')('modules')
|
|
|
|
// §2.5: shutdown races the process being killed, so a module that will not let
|
|
// go is logged and skipped rather than allowed to hang the exit. `onBoot` has no
|
|
// such budget on purpose — it delays the listener binding, which is the feature.
|
|
const SHUTDOWN_BUDGET_MS = 5000
|
|
|
|
/**
|
|
* Nudge the bot to re-pull the slash-command set, from the two places that
|
|
* actually change it in a live process: a boot, and an operator disabling a
|
|
* module (which `remove` and `purge` both run through).
|
|
*
|
|
* Enabling and installing are deliberately NOT here — both ask for a restart
|
|
* before the module runs, and a command whose handler is not registered yet is a
|
|
* command that would answer "unknown". The nudge follows the state, not the
|
|
* intention.
|
|
*
|
|
* Required lazily, and deliberately NOT awaited by either caller: the bot is
|
|
* optional infrastructure, and neither a boot nor an operator's disable should
|
|
* wait out `botInternalClient`'s 4s timeout because a bot container is wedged.
|
|
* Nothing here throws — a failed nudge is a log line, and the bot re-pulls on its
|
|
* next `ready` regardless.
|
|
*/
|
|
async function nudgeBot(why) {
|
|
try {
|
|
// eslint-disable-next-line global-require
|
|
const bot = require('../utils/botInternalClient')
|
|
const res = await bot.refreshCommands()
|
|
if (!res.ok) log.info('bot did not take the slash-command nudge', { why, error: res.error })
|
|
} catch (err) {
|
|
log.warn('slash-command nudge failed', { why, message: err.message })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run one database call for one module without letting it become everyone's
|
|
* failure. Returns null on failure, having logged it.
|
|
*
|
|
* The boot path is the whole reason this exists. A row that will not update is
|
|
* bad — the admin panel shows the wrong thing — but it is strictly less bad than
|
|
* a site that will not start, and it must not stop the modules after it from
|
|
* booting.
|
|
*/
|
|
async function safe(what, fn) {
|
|
try {
|
|
return await fn()
|
|
} catch (err) {
|
|
log.error(`module bookkeeping failed: ${what}`, { error: err.message })
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconcile `installed_modules` with what the loader found, then run every
|
|
* surviving module's `onBoot`.
|
|
*
|
|
* Called once from server.js, after `ensureSchema()` (so a module's own tables
|
|
* exist) and `seedDefaults()`, and **before the HTTP listener binds** — a module
|
|
* that must not serve traffic until it has warmed a cache gets that for free
|
|
* (§2.5).
|
|
*
|
|
* The order of the four steps is the whole design:
|
|
*
|
|
* 1. `beginBoot()` clears the last boot's outcomes, so what is on display
|
|
* afterwards is what THIS boot did. `disabled` rows are left alone: that is
|
|
* an operator decision, not an outcome (§2.4).
|
|
* 2. Every module on the volume gets a row, written with null provenance if it
|
|
* does not have one — a directory placed on the volume by hand is a
|
|
* supported install (§2.4/§2.5), and without a row it could never be
|
|
* disabled or shown as failed.
|
|
* 3. Rows with no directory are marked failed, because step 1 has just reset
|
|
* them to `enabled` and a row claiming to be enabled for a module that is
|
|
* not there is the one state that is simply untrue. (A plain uninstall
|
|
* leaves `disabled`, which step 1 never touches, so this only catches a
|
|
* directory deleted by hand.)
|
|
* 4. The outcome each module already carries — disabled by the operator,
|
|
* failed during load or schema replay, or ready — is written down, and only
|
|
* then is `onBoot` dispatched.
|
|
*
|
|
* Never throws. @param {object} [deps] injection seam for tests.
|
|
*/
|
|
async function boot({ modules, model } = {}) {
|
|
/* eslint-disable global-require */
|
|
const loader = modules || require('./loader')
|
|
const rows = model || require('../model/modules/modules.model')
|
|
/* eslint-enable global-require */
|
|
|
|
// Not an error, and the same guard replayFragments carries: a process that
|
|
// never required app.js has no scan to reconcile against, and writing rows
|
|
// from an empty list would mark every installed module as missing.
|
|
if (!loader.isLoaded()) {
|
|
log.info('no module scan in this process — skipping module boot')
|
|
return
|
|
}
|
|
|
|
const scanned = loader.list()
|
|
|
|
await safe('resetting last boot\'s outcomes', () => rows.beginBoot())
|
|
|
|
for (const m of scanned) {
|
|
await safe(`recording module "${m.id}"`, () => rows.recordInstalled({
|
|
id: m.id,
|
|
name: m.name,
|
|
version: m.version,
|
|
// Null provenance is what a hand-placed directory looks like, and it is
|
|
// all this step can honestly say: a scan finds a directory, never where it
|
|
// came from. An install through the admin panel writes source and sha256
|
|
// first, and this refresh must not undo that.
|
|
//
|
|
// It used to. `upsert` assigned both columns unconditionally, so every
|
|
// boot nulled them and an install's provenance survived only until the
|
|
// restart it asked for. The statement now COALESCEs — see the note on
|
|
// modules.db.js's upsert. Nothing could have caught it before Phase 4:
|
|
// this was the only caller, and it has never had a value to pass.
|
|
}))
|
|
}
|
|
|
|
const stored = (await safe('reading module rows', () => rows.list())) || []
|
|
const onVolume = new Set(scanned.map((m) => m.id))
|
|
|
|
for (const row of stored) {
|
|
if (onVolume.has(row.id) || row.state === 'disabled') continue
|
|
await safe(`marking module "${row.id}" missing`, () => rows.markStartupFailed(row.id, {
|
|
stage: 'require',
|
|
reason: 'module directory not present on the volume',
|
|
}))
|
|
}
|
|
|
|
const disabled = new Set(stored.filter((r) => r.state === 'disabled').map((r) => r.id))
|
|
|
|
for (const m of scanned) {
|
|
// The operator's switch wins over everything, including a failure. Its
|
|
// routes 404 from here on (§4.5 — the leg that was unreachable until this
|
|
// PR), it is not booted, and its failure is not re-recorded: overwriting a
|
|
// deliberate `disabled` with an outcome would silently re-enable it on the
|
|
// next boot.
|
|
if (disabled.has(m.id)) {
|
|
loader.setState(m.id, 'disabled')
|
|
continue
|
|
}
|
|
if (m.state === 'startup_failed') {
|
|
// Already failed in load() or the schema replay — both of which ran before
|
|
// the database was available to write it down. This is where it lands.
|
|
await safe(`recording failure for "${m.id}"`, () => rows.markStartupFailed(m.id, {
|
|
stage: m.stage,
|
|
reason: m.reason,
|
|
}))
|
|
}
|
|
}
|
|
|
|
// What a module SHIPS as engagement content — its message bodies and its
|
|
// seeded rules (ENGAGEMENT.md Phase 11b, decision 7).
|
|
//
|
|
// **Here rather than in `seedDefaults()`, and that is forced.** `server.js`
|
|
// seeds before it requires `app.js`, and requiring `app.js` is what scans the
|
|
// volume and runs the loader — so at the moment core seeds its own templates,
|
|
// no module has registered anything.
|
|
//
|
|
// **After the reconcile and before `onBoot`**, both deliberately: `disabled`
|
|
// is now known, so a module the operator switched off is skipped rather than
|
|
// having its rules quietly written; and a module that warms a cache in
|
|
// `onBoot` may assume its rules and bodies exist by then.
|
|
//
|
|
// Failed modules are skipped for the stronger reason. A module whose require
|
|
// or schema replay failed has registered nothing anyway — but one whose ROW
|
|
// says `startup_failed` may have registered before failing later, and seeding
|
|
// content for a module that is about to answer 503 puts rows in the operator's
|
|
// Rules screen for a thing that is not running.
|
|
const skip = new Set([
|
|
...disabled,
|
|
...scanned.filter((m) => m.state === 'startup_failed').map((m) => m.id),
|
|
])
|
|
await safe('seeding module engagement content', () =>
|
|
// eslint-disable-next-line global-require
|
|
require('../engagement/moduleSeeds').seedModuleEngagement({ skip }))
|
|
|
|
for (const { id, hook, ctx } of loader.bootable()) {
|
|
try {
|
|
// Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the
|
|
// listener, which is the contract's promise to a module that must warm up
|
|
// before it serves. Core's own boot steps are awaited the same way.
|
|
if (hook) await hook(ctx)
|
|
loader.setState(id, 'started')
|
|
await safe(`marking module "${id}" started`, () => rows.markStarted(id))
|
|
log.info(`module "${id}" started`)
|
|
} catch (err) {
|
|
// §4.4's second column: the routes are already mounted, so they stay
|
|
// mounted and answer 503. A module that failed to warm up serving
|
|
// half-initialised data is worse than one that says it is down.
|
|
loader.setState(id, 'startup_failed', { stage: 'boot', reason: err.message })
|
|
await safe(`recording boot failure for "${id}"`, () => rows.markStartupFailed(id, {
|
|
stage: 'boot',
|
|
reason: err.message,
|
|
}))
|
|
log.error(`module "${id}" onBoot failed — its routes will answer 503`, {
|
|
reason: err.message,
|
|
})
|
|
}
|
|
}
|
|
|
|
// The Team reconciler's boot trigger (TEAMS.md §2.4), last — after every module
|
|
// has started, because the provider is registered by a module and a module that
|
|
// warms a cache in onBoot must be allowed to finish before it is asked anything.
|
|
//
|
|
// `safe` for the same reason every step above uses it: an unreachable provider
|
|
// is a stale projection, never a site that will not start.
|
|
// eslint-disable-next-line global-require
|
|
await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start())
|
|
|
|
// Tell the bot the slash-command set may have moved (TEAMS.md §7.1).
|
|
//
|
|
// The bot pulls on its own `ready` too, so this is not the only path — it is
|
|
// the path for the case `ready` does not cover: the APP restarting while the
|
|
// bot stays connected, which is every ordinary redeploy. Without it, a module
|
|
// added in that deploy has no command until someone restarts the bot.
|
|
nudgeBot('boot')
|
|
}
|
|
|
|
/** Reject if `fn`'s promise has not settled within `ms`. */
|
|
function withBudget(fn, ms) {
|
|
let timer
|
|
const budget = new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error(`onShutdown exceeded its ${ms}ms budget`)), ms)
|
|
})
|
|
return Promise.race([Promise.resolve().then(fn), budget]).finally(() => clearTimeout(timer))
|
|
}
|
|
|
|
/**
|
|
* Run every started module's `onShutdown`, in reverse registration order.
|
|
*
|
|
* Called from server.js's signal handler before anything core owns is closed, so
|
|
* a module still has a working database pool and push dispatcher to flush
|
|
* through. Reverse order is the mirror of boot order: a module that booted after
|
|
* another may be holding something the earlier one handed it.
|
|
*
|
|
* Never throws, and never hangs: each hook gets `SHUTDOWN_BUDGET_MS`, after
|
|
* which it is logged and abandoned. Abandoned, not cancelled — nothing can stop
|
|
* a promise that is still running — but the process is exiting anyway, and the
|
|
* alternative is a shard host where `systemctl stop` hangs until SIGKILL.
|
|
*/
|
|
async function shutdown({ modules, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
|
|
// eslint-disable-next-line global-require
|
|
const loader = modules || require('./loader')
|
|
if (!loader.isLoaded()) return
|
|
|
|
for (const { id, hook } of loader.shutdownHooks()) {
|
|
try {
|
|
await withBudget(hook, budgetMs)
|
|
log.info(`module "${id}" shut down`)
|
|
} catch (err) {
|
|
log.warn(`module "${id}" onShutdown failed or timed out — continuing`, {
|
|
error: err.message,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop ONE module and mark it disabled — the admin panel's Disable (§2.7.2
|
|
* decision 3).
|
|
*
|
|
* Phase 2 built disable as a pure state flip: the record moved to `disabled` and
|
|
* the dispatch guard started answering 404. That makes a module invisible, not
|
|
* stopped. Everything a module does that is not a response to a request — the
|
|
* sockets and timers its `onBoot` armed — carried on running, so an operator
|
|
* disabling a module *because* it was misbehaving got nothing until the next
|
|
* restart, which is the one thing the button was supposed to save them.
|
|
*
|
|
* So the hook runs first, and the state moves after it: while `onShutdown` is
|
|
* running the module is still `started`, which is the only state in which its
|
|
* own routes and the things it is tearing down are consistent with each other.
|
|
* The hook gets the same budget the exit path gives it.
|
|
*
|
|
* A hook that throws does NOT stop the disable. The operator asked for this
|
|
* module to stop answering; a module that could not close cleanly is a reason to
|
|
* log loudly, not a reason to leave it serving. That is the opposite of the boot
|
|
* path's rule, and deliberately: there, a failure means the module never became
|
|
* safe to use.
|
|
*
|
|
* **Enable is not the mirror of this, and there is no `start(id)` beside it.**
|
|
* There is no `onBoot` re-dispatch, and MODULE_API.md has never promised the
|
|
* hooks are re-entrant — no module author has written `onBoot` to be safe to run
|
|
* twice in one process. Re-enabling therefore moves the row and waits for a
|
|
* restart, which the admin screen offers.
|
|
*
|
|
* @returns {Promise<{stopped: boolean, error: string|null}>} whether a hook ran.
|
|
*/
|
|
async function stop(id, { modules, model, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
|
|
/* eslint-disable global-require */
|
|
const loader = modules || require('./loader')
|
|
const rows = model || require('../model/modules/modules.model')
|
|
/* eslint-enable global-require */
|
|
|
|
let error = null
|
|
let stopped = false
|
|
|
|
if (loader.isLoaded()) {
|
|
const target = loader.stopHook(id)
|
|
if (target) {
|
|
try {
|
|
await withBudget(target.hook, budgetMs)
|
|
stopped = true
|
|
log.info(`module "${id}" stopped by an operator`)
|
|
} catch (err) {
|
|
error = err.message
|
|
log.warn(`module "${id}" onShutdown failed or timed out while being disabled — disabling anyway`, {
|
|
error: err.message,
|
|
})
|
|
}
|
|
}
|
|
// Unconditional, and after the hook: this is what makes its routes and its
|
|
// client chunk answer 404 (§4.5). A module that is not loaded in this
|
|
// process has no record to move, and setState ignores an unknown id.
|
|
loader.setState(id, 'disabled')
|
|
}
|
|
|
|
await safe(`disabling module "${id}"`, () => rows.disable(id))
|
|
nudgeBot(`disable:${id}`)
|
|
return { stopped, error }
|
|
}
|
|
|
|
module.exports = { boot, shutdown, stop, SHUTDOWN_BUDGET_MS }
|