Files
website/server/src/modules/lifecycle.js
wtclaude cfd1cb3c3c feat(engagement): let a module ship its own templates and rules (Phase 11b)
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>
2026-09-01 00:46:15 -05:00

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 }