// ── 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 }