fix(core): the last fixes' core half (W1 W2 W5 W19)
Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, with the org lead's 2026-10-22 decisions. W19: one module per site at boot, as at install. The first module directory by name holds the site, whether or not it then loads; every other is recorded startup_failed at the manifest stage, before a line of its code is required, with a reason naming the module that holds the site. Module-uo and Module-Rust side by side used to fail the second with a slot collision. Loader, lifecycle, schema and event-contract tests that loaded several fixture modules now load each case alone. W1: a reconcile files a resource the game no longer has, and that is past its `until`, as expired rather than orphaned, with the same log line expired() writes. No MODULE_API bump: `until` and reconcile's answer are unchanged. W5: a dry run that passes on the draft is kept with the fingerprint of the validated spec (three nullable columns on event_definitions). Publish stamps the new version verified, with that dry run's reviewer and time, when the spec it freezes has the same fingerprint, and writes version.verified on the occurrences it re-pins. Verify answers draftRecorded and publish answers verified; OpenAPI regenerated. W2: the run console says "Phase p1: steps done, waiting to advance" and "Why this phase has not advanced", not "has not started". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -813,10 +813,26 @@ function load(tierRouters) {
|
||||
return // no modules directory is the normal case for a bare core
|
||||
}
|
||||
|
||||
// One module per site, at boot as at install (`install.js`, org lead
|
||||
// 2026-09-23; W19 in Rust PLAN_LAST_FIXES, 2026-10-22). Install already
|
||||
// refuses a second module, so two on the volume means someone copied one in by
|
||||
// hand. The contract's singletons assume one: Module-uo and Module-Rust both
|
||||
// fill `admin.users.detail`, and the second used to fail at register with a
|
||||
// slot collision that named neither the rule nor the fix. Now the first in
|
||||
// scan order is the site's module, whether or not it then loads, and every
|
||||
// other is refused before a line of its code is required.
|
||||
let holder = null
|
||||
|
||||
for (const id of entries) {
|
||||
const dir = path.join(MODULES_DIR, id)
|
||||
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
|
||||
|
||||
if (holder) {
|
||||
refuseSecond(id, dir, holder)
|
||||
continue
|
||||
}
|
||||
holder = id
|
||||
|
||||
const record = {
|
||||
id,
|
||||
dir,
|
||||
@@ -886,8 +902,10 @@ function load(tierRouters) {
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered') continue
|
||||
try {
|
||||
// Commit what this module staged. Collisions with core or with an earlier
|
||||
// module surface here, in scan order, and cost only this module.
|
||||
// Commit what this module staged. Collisions with core surface here and
|
||||
// cost only this module. (An earlier module is no longer possible — one
|
||||
// module per site, above — and the module-against-module checks stay as
|
||||
// the second line behind that rule.)
|
||||
registries.apply(record.staged.staged)
|
||||
} catch (err) {
|
||||
record.state = 'startup_failed'
|
||||
@@ -902,6 +920,45 @@ function load(tierRouters) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a module refused because another already holds the site (W19).
|
||||
*
|
||||
* `startup_failed` at the `manifest` stage, like any module that never reached
|
||||
* register: no routes, no nav, no tables, and Admin → Modules says why. The
|
||||
* name and version are read for that panel and nothing else; a module.json that does not
|
||||
* parse is still refused for the same reason, since the reason is not its
|
||||
* contents.
|
||||
*/
|
||||
function refuseSecond(id, dir, holder) {
|
||||
const manifest = { id, version: 'unknown' }
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'))
|
||||
if (raw && typeof raw.version === 'string') manifest.version = raw.version
|
||||
if (raw && typeof raw.name === 'string') manifest.name = raw.name
|
||||
} catch {
|
||||
// the panel shows "unknown"; the refusal stands either way
|
||||
}
|
||||
const reason =
|
||||
`this site already runs the module "${holder}", and a site runs one module. ` +
|
||||
`Remove "${id}" or "${holder}" from the modules directory; the one that sorts first by name is the one that starts.`
|
||||
modules.set(id, {
|
||||
id,
|
||||
dir,
|
||||
manifest,
|
||||
routes: { public: new Map(), admin: new Map(), player: new Map() },
|
||||
staged: null,
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'startup_failed',
|
||||
stage: 'manifest',
|
||||
reason,
|
||||
})
|
||||
log.error(`module "${id}" was not started — continuing without it`, { stage: 'manifest', reason })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount one module's routers onto the tier routers, behind the dispatch guard.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user