feat(modules): the declarative Docker path (phase 4, slice 3)
Some checks failed
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Failing after 4m23s

MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.

Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.

A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.

Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.

741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-12 07:57:08 -05:00
parent 75f4d29e93
commit 9b16f39a52
15 changed files with 1090 additions and 28 deletions

View File

@@ -6,13 +6,15 @@
// runner here has no DOM, and this is the part of the Modules screen that is
// actually worth testing.
//
// **The screen has three sources of truth and they are allowed to disagree**
// (MODULE_SYSTEM.md §2.4):
// **The screen has four sources of truth and they are allowed to disagree**
// (MODULE_SYSTEM.md §2.4, and slice 3 for the fourth):
//
// state what the DATABASE row records — what the operator decided, and
// what the last boot ended up doing
// liveState what the LOADER has mounted in this process and is answering with
// onVolume whether there is still a directory there at all
// declared what this container's MODULES variable asks for — the only one of
// the four that no button on this screen can change
//
// Picking one and rendering it would be simpler and would lie. The case that
// makes this concrete is the one decision 3 creates on purpose: an operator
@@ -34,6 +36,22 @@
* @returns {{ label: string, tone: 'ok'|'warn'|'bad'|'idle', pending: boolean, detail: string }}
*/
export function statusOf(m) {
// Declared by the environment and not there at all: no row, no directory,
// nothing mounted. Every other branch below reads one of those three, so
// without this the screen would describe a module it has never had as though
// a row had gone stale — and the one thing the operator needs, the reason
// resolution failed, would be nowhere.
if (m.declared && !m.onVolume && m.state === null) {
return {
label: 'Declared, not installed',
tone: 'bad',
pending: false,
detail: m.declaredError
? `MODULES asks for v${m.declaredVersion}; the last start could not install it: ${m.declaredError}`
: `MODULES asks for v${m.declaredVersion}. It will be installed when the server next starts.`,
}
}
// Gone from the volume, but still known. Either a hand-deleted directory (the
// boot reconcile marks that `startup_failed`) or an uninstall waiting for its
// restart. Both are "there is nothing to run here".
@@ -125,6 +143,43 @@ export function statusOf(m) {
}
}
/**
* What the environment's declaration means for this module, as one sentence — or
* null if nothing declares it.
*
* Kept out of `statusOf` on purpose. A module can be running perfectly while its
* declared upgrade is failing, and collapsing both into one label would have to
* pick which of the two is "the" status. This is a second line, beside the first.
*
* The sentence an operator most needs is the uninstall one: MODULES owns what is
* on the volume and the row owns whether it runs, so uninstalling a declared
* module puts its files back at the next start and leaves it switched off. Files
* reappearing unexplained is exactly the kind of thing that gets debugged for an
* afternoon.
*
* @param {object} m a row from GET /admin/modules
* @returns {{ text: string, tone: 'warn'|'idle' }|null}
*/
export function declarationNoteFor(m) {
if (!m.declared) return null
if (m.declaredError) {
return {
text: `MODULES asks for v${m.declaredVersion} and the last start could not install it: ${m.declaredError}`,
tone: 'warn',
}
}
if (!m.onVolume) {
return {
text:
`MODULES declares v${m.declaredVersion}, so its files come back when the server next starts`
+ (m.state === 'disabled' ? ' — switched off, until you enable it.' : '.'),
tone: 'warn',
}
}
return { text: `Declared by this deployment's MODULES variable at v${m.declaredVersion}.`, tone: 'idle' }
}
/**
* Which actions are offered for a module, and why the others are not.
*