// The module state machine (docs/website/MODULE_SYSTEM.md §2.4, MODULE_API.md §4.4). // // installed ──► enabled ──► started // │ │ // │ └──► startup_failed ──┐ // │ │ (retry) // └──────────────► disabled ◄─────────┘ // // One row per installed module, one column holding the state. The rules that make // the machine mean anything live here, not in the SQL: // // - `installed` is transient. An install writes the row; the restart that follows // resolves it to `started` or `startup_failed` (§2.5). // - `disabled` is the only state a boot leaves alone. It is the operator's // decision; every other state is an outcome and is recomputed each boot by // beginBoot(). That is what makes a fixed module recover on restart without // anyone visiting the admin panel. // - A failure is recorded with the stage it happened at, and every non-failing // transition clears it — a running module can never show a stale reason. // // What this table does NOT decide is which routes exist. The loader scans the // filesystem at require time, before the database is reachable (MODULE_API.md §4.1), // so a disabled module is still mounted and simply guarded (§4.5). Keeping the URL // surface a property of the volume is what lets routes.manifest.json be generated // off a dead database. const db = require('./modules.db') const STATES = ['installed', 'enabled', 'disabled', 'started', 'startup_failed'] // The stage a failure happened at: MODULE_API.md §4.3's seven validation steps, // plus `boot` for an onBoot hook that threw (§2.5). const FAILURE_STAGES = [ 'manifest', 'core_api', 'mounts', 'extensions', 'schema', 'require', 'register', 'boot', ] class ModuleStateError extends Error { constructor(code, message) { super(message) this.name = 'ModuleStateError' this.code = code } } // Legal moves, keyed by target state. Anything not listed is a bug in the caller // and throws rather than writing a row that misrepresents what happened. const ALLOWED_FROM = { // Enabling is the recovery path as well as the first step: a disabled module the // operator switches back on, and a startup_failed one they retry, both land here. enabled: ['installed', 'enabled', 'disabled', 'startup_failed', 'started'], // The operator may disable a module in any state, including one that is running. disabled: STATES, // Reached from `enabled` on a normal boot, and from `installed` on the first boot // after an install (or for a directory placed on the volume by hand, whose row is // written moments earlier in the same boot). started: ['installed', 'enabled'], // Failure always precedes `started` in the lifecycle; `started` is accepted so a // late failure can still be recorded truthfully rather than dropped. startup_failed: ['installed', 'enabled', 'started'], } // row → API shape. function serialize(row) { if (!row) return null return { id: row.id, name: row.name, version: row.version, state: row.state, failureStage: row.failure_stage ?? null, failureReason: row.failure_reason ?? null, source: row.source ?? null, sha256: row.sha256 ?? null, installedAt: row.installed_at ?? null, startedAt: row.started_at ?? null, updatedAt: row.updated_at ?? null, } } async function list() { const rows = await db.listAll() return rows.map(serialize) } async function get(id) { const rows = await db.getOne(id) return serialize(rows[0]) } // Record an install (or a re-install / upgrade). Metadata is refreshed; the state is // left as it is, so upgrading an enabled module does not switch it off and // re-installing a disabled one does not switch it on. A new row lands in `installed`. async function recordInstalled({ id, name, version, source = null, sha256 = null }) { if (!id || !name || !version) { throw new ModuleStateError('invalid_module', 'id, name and version are required') } await db.upsert({ id, name, version, source, sha256 }) return get(id) } // Start of boot: clear the last boot's outcomes so what is on display after this // boot is what this boot did. Leaves `disabled` rows alone (see the header). // Returns the number of rows reset. async function beginBoot() { const res = await db.resetForBoot() return res?.affectedRows ?? 0 } // Apply one transition, after checking it is legal for the row's current state. // A row that does not exist is not an error the caller can act on — a module can be // present on the volume with no row at all — so it returns null and writes nothing. async function transition(id, target, { failureStage = null, failureReason = null } = {}) { const current = await get(id) if (!current) return null const allowed = ALLOWED_FROM[target] if (!allowed.includes(current.state)) { throw new ModuleStateError( 'illegal_transition', `module '${id}': cannot move from '${current.state}' to '${target}'`, ) } await db.setState({ id, state: target, failureStage, failureReason, stampStarted: target === 'started', }) return get(id) } const enable = (id) => transition(id, 'enabled') const disable = (id) => transition(id, 'disabled') const markStarted = (id) => transition(id, 'started') // Record a failure at a named stage. Two deliberate softenings, both because this is // called from the boot path where throwing would turn one module's failure into // everybody's (MODULE_API.md §4.4 — the failing module fails alone): // // - a `disabled` row is a no-op. The operator switched it off; a broken module // they already disabled is not news, and overwriting their decision with an // outcome would silently re-enable it on the next boot. // - an unrecognised stage is recorded as `require` rather than rejected, so a // miscategorised failure still reaches the admin panel with its reason intact. async function markStartupFailed(id, { stage, reason }) { const current = await get(id) if (!current || current.state === 'disabled') return current return transition(id, 'startup_failed', { failureStage: FAILURE_STAGES.includes(stage) ? stage : 'require', failureReason: String(reason ?? 'unknown error').slice(0, 4000), }) } // Purge only (§2.5). A plain uninstall disables the module and keeps its row, so its // data survives and the admin panel can still show what was there. async function remove(id) { await db.remove(id) } module.exports = { STATES, FAILURE_STAGES, ModuleStateError, list, get, recordInstalled, beginBoot, enable, disable, markStarted, markStartupFailed, remove, }