10 Commits

Author SHA1 Message Date
224e2b4dbc fix(events): settle a finished run whose last resource expired while it ran
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 20m48s
Found walking Rust PLAN_FIXES step 2 (run 46 on the walk core): a zone expired
while its run was still going, so expireResource left the run's cleanup status
to its terminal path. When the run was cancelled the sweep never selected it,
because runsNeedingCleanup joins on an unresolved row and it had none, so
cleanup_status sat at `pending` for ever over an empty ledger.

The sweep now settles finished runs that are `pending` with no unresolved row
as `complete` (never over `incomplete`, which is a human's to clear). Checked
against the walk database: the query returns run 46 and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 22:19:17 -05:00
cc1f49af29 feat(events): an expired ledger status and ctx.events.expired (MODULE_API 1.11.0)
All checks were successful
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 5m48s
PR Checks / bot-tests (pull_request) Successful in 7m51s
A game that ends a resource at its own deadline — a Rust zone erased when its
time is up — could reach core only through ctx.events.reconcile(), which files
it `orphaned`: amber, "it vanished", and still claimable for a revert. The new
call files it as the plan working (Rust PLAN_FIXES F14, D170, D183):

- event_run_resources.status gains `expired`, terminal like `reverted`: the
  sweep never takes it back, live_marker releases the target, and it joins
  neither HELD nor UNRESOLVED. The ENUM ALTER re-runs as a no-op on every boot
  (checked on MariaDB 11.8 with the stored generated column depending on it).
- ctx.events.expired({ kind, ref }) marks the calling module's own pending,
  confirmed or orphaned rows for that target expired and logs
  `resource.expired`; a revert in flight is left to finish. The owner is bound
  by the loader, like reconcile. A finished run whose last unresolved row this
  was goes to cleanup `complete`, even from `incomplete`.
- The run console shows it green, "ended by the game on time".

Additions only, so minor. Module-uo (coreApi ^1.10.0) calls none of it and
reads no ledger status; the contract test now asserts the range still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:13:07 -05:00
1a76b98d76 Merge pull request 'fix(modules): a site runs one module; the installer refuses a second' (#204) from fix/one-module-per-site into main
All checks were successful
sync-project-tree / sync (push) Successful in 10s
Build container images / build (push) Successful in -2s
Build container images / deploy (push) Successful in 1m46s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #204
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 22:28:57 +00:00
a169a463a5 Merge branch 'main' into fix/one-module-per-site
All checks were successful
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 5m47s
2026-09-26 22:22:56 +00:00
90ba8cca16 Merge pull request 'feat(events): publish runId on a public calendar run entry (Rust D125)' (#208) from feat/public-calendar-run-id into main
Some checks failed
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 1m50s
Build container images / deploy (push) Successful in 42s
SonarQube / analysis (push) Failing after 6m11s
Reviewed-on: #208
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-25 17:43:50 +00:00
b5616f359c feat(events): publish runId on a public calendar run entry (Rust D125)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 39s
PR Checks / client-build (pull_request) Successful in 42s
PR Checks / server-tests (pull_request) Successful in 5m54s
A run entry on GET /public/events now names its run, the same id the
event page already publishes on each occurrence and `?run=` takes. A
Rust map marker carries core's run id and nothing else about its event,
so without this the app could only find the event by fetching every
event page.

A projected entry has no runId: nothing is committed to it. Rehearsals
and unlisted events stay absent from the calendar, so their markers
stay unlinked. The web calendar ignores the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 07:38:59 -05:00
e4f088e90b fix(modules): a site runs one module; the installer refuses a second
All checks were successful
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / bot-tests (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 13m28s
A site is one game, and the module contract already has singletons that
assume it. registerTeamProvider holds one value per deployment, and a
second module registering one fails that module's whole load. The loader
scans alphabetically, so installing module-rust (which gains a Team
provider in its phase 9) beside module-uo would have taken uo down, not
rust.

install() now refuses, with 409 and before the artifact is downloaded,
any install whose id differs from a module already on the volume. An
upgrade of the installed module is still accepted; to change game,
remove the module first. Both install surfaces share this path, so a
MODULES declaration naming two modules installs the first and reports
the second as refused without failing the boot.

"Installed" means what the loader would scan: a directory named with a
module id that holds a module.json. An install's scratch directory and a
swap's aside copy do not count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 01:26:52 -05:00
702ab89ae2 Merge pull request 'fix(admin): stop a long action name printing over the activity row beside it' (#203) from fix/activity-action-overflow into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
Build container images / build (push) Successful in 1m29s
Build container images / deploy (push) Successful in 46s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #203
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-21 22:24:29 +00:00
9e1f591b25 fix(admin): stop a long action name printing over the activity row beside it
All checks were successful
PR Checks / client-build (pull_request) Successful in 57s
PR Checks / server-tests (pull_request) Successful in 6m9s
PR Checks / bot-tests (pull_request) Successful in 8m3s
The dashboard renders an activity row action in a fixed `width: 110` span with
`flex: none` and no overflow handling, so a name wider than that overflows its
box and prints on top of the detail text next to it.

Core own actions all fit. A module one need not: `module-rust` writes
`rust.account.unlink.staff` when staff sever a player Steam link, and it
overlapped `steamId: …` on a live dashboard. `module-uo` `uoLink.account.link`
is already close to the edge.

`minWidth` instead of `width` keeps the column aligned for every short name and
lets a longer one push the detail right rather than sit under it. One property,
verified in a browser with both rows on screen.

Found while walking module-rust phase 6; raised here rather than worked around
there, because a module may legitimately name an action and shortening one
module names only moves the ceiling to the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 09:10:43 -05:00
efa9db7330 Merge pull request 'chore(tools): delete the cliloc converter the Asset Bridge replaced (Asset Bridge cutover, 2 of 5)' (#202) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 34s
Build container images / build (push) Successful in 22s
Build container images / deploy (push) Successful in 38s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #202
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-15 10:20:24 +00:00
22 changed files with 554 additions and 15 deletions

View File

@@ -11,6 +11,10 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.11.0 — `ctx.events.expired({ kind, ref })` and the ledger's `expired` status
// (Rust PLAN_FIXES D183): a module may say the game ended a ledgered resource at
// its own deadline. Server-side only; the run console, which is core's own page,
// shows the new status. This file bumps for the reason at the top.
// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
// Phase 7): a module may register event actions, budget dimensions, leases and
// param option sources. All four are server-side registrations and nothing on
@@ -74,4 +78,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.10.0'
export const MODULE_API_VERSION = '1.11.0'

View File

@@ -166,7 +166,12 @@ export default function Dashboard() {
className="sans"
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
>
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{/* `minWidth` rather than `width`: the column still lines up for core's
own short action names, and a longer one — a module's namespaced
action, say `rust.account.unlink.staff` — grows the box instead of
overflowing it and printing on top of the detail beside it. Found
on a live dashboard with a module installed. */}
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', minWidth: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{a.action}
</span>
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>

View File

@@ -59,7 +59,7 @@ const STATUS_COLOR = {
completed: '#8fc79a',
}
// The six ledger statuses, in the two groups that matter to a reader: green is
// The seven ledger statuses, in the two groups that matter to a reader: green is
// resolved, amber wants a person. `orphaned` and `drifted` are amber rather than
// red because neither is a fault — one thing vanished, the other was taken by
// somebody with every right to take it — and red is reserved for "this did not
@@ -71,6 +71,9 @@ const RESOURCE_COLOR = {
reverting: '#d9c184',
drifted: '#d9c184',
orphaned: '#d9c184',
// Green, like `reverted`: the game ended it at the deadline it was given, which
// is the plan working (MODULE_API 1.11.0). `orphaned` is the amber one.
expired: '#8fc79a',
}
const RESOURCE_WORD = {
@@ -80,6 +83,7 @@ const RESOURCE_WORD = {
reverted: 'given back',
orphaned: 'gone',
drifted: 'someone else moved it',
expired: 'ended by the game on time',
}
const STEP_COLOR = {

View File

@@ -2560,7 +2560,7 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
-- defensible. This column is core's copy of that promise, for the console and
-- for the boot-time check.
lease_until DATETIME NULL,
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired')
NOT NULL DEFAULT 'pending',
-- Bounded like a step's `attempts`, and for the same reason: a revert that can
-- never succeed must become visible rather than cycling for ever. Engagement
@@ -2587,6 +2587,14 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
INDEX idx_evres_live (status, lease_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- MODULE_API 1.11.0 (Rust PLAN_FIXES D183): `expired`, the game ending a resource
-- at its own deadline, for databases created before it. Terminal, so `live_marker`
-- releases the target. MODIFY has no IF NOT EXISTS form, but re-declaring the same
-- ENUM is an idempotent no-op -- checked against MariaDB 11.8 with the stored
-- `live_marker` column depending on it -- so it is safe on every boot.
ALTER TABLE event_run_resources MODIFY COLUMN status
ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired') NOT NULL DEFAULT 'pending';
-- §K's last bound: "a scheduled definition that has never been verified is the
-- case worth refusing to start". A version is immutable, so a dry run that passed
-- against it stays true — which is what makes the pass a property of the VERSION

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.10.0",
"moduleApiVersion": "1.11.0",
"triggers": [
{
"id": "event.phase.changed",

View File

@@ -320,6 +320,16 @@ async function sweep() {
// cleanup, whose counters were spent deliberately.
const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
let swept = 0
// **A finished run with nothing left to give back is complete** (MODULE_API
// 1.11.0). The scan above joins on an unresolved row, and until `expired` no
// run could end `pending` without one. Now a zone can expire while its run is
// still going — `expireResource` leaves a live run's status to its terminal
// path — and when that run ends, nothing in the scan would ever select it:
// `pending` for ever. Found by the step-2 walk, run 46.
for (const runId of await resourcesDb.finishedRunsWithNothingLeft(CLEANUP_RUN_BATCH)) {
if (await runsDb.setCleanupStatus(runId, 'complete', ['pending'])) swept += 1
}
for (const candidate of candidates) {
if (!runsDb.TERMINAL.includes(candidate.status)) continue
try {
@@ -427,6 +437,64 @@ async function reconcileAll() {
return out
}
// The bounds on what a module may name. They are the columns' own: a longer
// value cannot be a row, so it is refused rather than truncated into a match
// against somebody else's.
const MAX_KIND = 64
const MAX_REF = 190
/**
* The game ended one of this module's resources by itself, as it was told to —
* `ctx.events.expired({ kind, ref })`, MODULE_API 1.11.0 (Rust PLAN_FIXES D183).
*
* **Expired is not orphaned, and the difference is the reason this exists.** A
* Rust zone is created with a deadline and the game erases it when the deadline
* passes, without being asked again — the fail-safe that makes an unattended
* world change defensible. Until a module could say so, core learned of it only
* through reconcile, which files it as `orphaned`: a thing that vanished while
* nobody was looking, amber on the console and still claimable for a revert.
* An expiry is the plan working, so it is terminal and green like `reverted`.
*
* `owner` is bound by the loader, never taken from the module's arguments, for
* `reconcile`'s reason: without the binding a module could close another
* module's rows. Answers `{ expired }`; nothing matching is `{ expired: 0 }`,
* because a module hears expiries of things no run ledgered too.
*/
async function expireResource(owner, { kind, ref } = {}) {
if (typeof kind !== 'string' || !kind || kind.length > MAX_KIND) return { expired: 0 }
if (typeof ref !== 'string' || !ref || ref.length > MAX_REF) return { expired: 0 }
const rows = await resourcesDb.expirableByTarget(owner, kind, ref)
const runs = new Set()
let expired = 0
for (const row of rows) {
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) continue
expired += 1
runs.add(row.run_id)
await logDb.write({
runId: row.run_id,
kind: 'resource.expired',
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
})
}
// A finished run whose last unresolved row this was has nothing left to clean
// up. The sweep would settle a `pending` one by itself, but not an
// `incomplete` one — that status takes a run out of the sweep's scan, and it
// would go on saying "not finished" about a ledger that is. A run still in
// flight is left alone: its own terminal path computes the status.
for (const runId of runs) {
const run = await runsDb.getById(runId)
if (!run || !runsDb.TERMINAL.includes(run.status)) continue
if ((await resourcesDb.unresolvedCount(runId)) === 0) {
await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete'])
}
}
return { expired }
}
module.exports = {
MAX_REVERT_ATTEMPTS,
classifyRevert,
@@ -434,4 +502,5 @@ module.exports = {
sweep,
reconcileModule,
reconcileAll,
expireResource,
}

View File

@@ -103,9 +103,18 @@ function phaseLabel(spec, phaseId) {
return (phase && (phase.label || phase.id)) || null
}
/** One calendar entry, from a materialised run. */
/**
* One calendar entry, from a materialised run.
*
* **`runId` is published because the event page already publishes it** on every
* occurrence, and `?run=` takes it. The calendar was the one public shape that
* named a run without saying which, so a client holding a run id from elsewhere
* (a module's map marker) had no way to find its event but to fetch every event
* page. A projection has none: nothing is committed to it.
*/
const publicRunEntry = (run) => ({
kind: 'run',
runId: run.id,
title: run.definition_title,
slug: run.definition_slug,
seriesName: run.series_name || null,

View File

@@ -53,6 +53,9 @@ const KINDS = [
// question is about the world.
'resource.recorded', // a step reported what it created or borrowed, and it is ledgered
'resource.orphaned', // a module reports a ledgered resource is no longer in force
// MODULE_API 1.11.0's one (Rust PLAN_FIXES D183): the game ended a resource at
// its own deadline, which is the plan working rather than something vanishing.
'resource.expired', // a module reports the game ended a ledgered resource by itself
'cleanup.reverted', // a group of resources came back
'cleanup.failed', // a group did not, with the reason and how it was left
'cleanup.swept', // one pass over a run's ledger, and what it found

View File

@@ -327,6 +327,42 @@ async function markOrphaned(id, detail = null) {
)
}
/**
* The rows one module holds for one target that the game may have let go of on
* its own — what `ctx.events.expired` looks for (MODULE_API 1.11.0).
*
* `orphaned` is included on purpose: a reconcile that ran before the module heard
* the expiry filed the row as vanished, and the expiry is the better answer.
* `reverting` is not: a revert already in flight finds the thing gone, which §L
* calls a success, and it settles as `reverted` on its own.
*/
async function expirableByTarget(owner, kind, ref) {
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_resources
WHERE owner_module = ? AND kind = ? AND ref = ?
AND status IN ('pending', 'confirmed', 'orphaned')
ORDER BY id`,
[owner, kind, ref],
)
return rows.map(hydrate)
}
/**
* The game ended it by itself, as it was told to — a zone reaching its deadline.
* Terminal like `reverted`, so teardown never tries to give it back, and unlike
* `orphaned`, which is a thing that vanished with nobody asking. Guarded on the
* status so a row a revert has just claimed is left to that revert.
*/
async function markExpired(id, detail = null) {
const result = await query(
`UPDATE event_run_resources
SET status = 'expired', last_error = ?
WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned')`,
[detail === null ? null : String(detail).slice(0, 500), id],
)
return (result.affectedRows || 0) > 0
}
/**
* Terminal runs that still owe the world something — the cleanup leg's scan.
*
@@ -366,8 +402,30 @@ async function runsNeedingCleanup(limit = 25, maxAttempts = 3) {
)
}
/**
* Finished runs still marked `pending` that have no unresolved row at all — a
* run whose last resource the game expired while it was still going (MODULE_API
* 1.11.0). The sweep settles them `complete`; `runsNeedingCleanup` cannot see
* them because it joins on an unresolved row.
*/
async function finishedRunsWithNothingLeft(limit = 25) {
const rows = await query(
`SELECT r.id FROM event_runs r
WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed')
AND r.cleanup_status = 'pending'
AND NOT EXISTS (
SELECT 1 FROM event_run_resources res
WHERE res.run_id = r.id AND res.status IN (?, ?, ?, ?, ?))
ORDER BY r.id
LIMIT ?`,
[...UNRESOLVED, Number(limit)],
)
return rows.map((row) => Number(row.id))
}
module.exports = {
STEP_KIND,
finishedRunsWithNothingLeft,
HELD,
UNRESOLVED,
reserve,
@@ -385,5 +443,7 @@ module.exports = {
liveForModule,
modulesWithLiveRows,
markOrphaned,
expirableByTarget,
markExpired,
runsNeedingCleanup,
}

View File

@@ -17,6 +17,11 @@
// through modules/install.js, the same fetch-verify-unpack path the admin panel
// uses, under the same host allowlist.
//
// **A site runs one module** (org lead, 2026-09-23), and install.js enforces it
// for this path too: a declaration naming a second module installs the first,
// and the second is refused — logged and kept for the admin screen like any
// other failed entry, never fatal to the boot.
//
// Three things this file deliberately does not do:
//
// - **It does not decide whether a module RUNS.** Resolution owns what is on

View File

@@ -290,6 +290,28 @@ function isInstalled(id) {
}
}
/**
* Every module on the volume, by id — the directories the loader would scan.
*
* The loader's own rule, restated: a directory whose name is a module id and
* which holds a `module.json`. That excludes an install's scratch directory
* (`.install-*`) and a swap's aside copy (`<id>.replaced-*`), neither of which
* is a module and both of which can briefly exist beside one.
*/
function installedIds() {
let entries
try {
entries = fs.readdirSync(loader.dir(), { withFileTypes: true })
} catch {
return [] // no modules directory is the normal case for a bare core
}
return entries
.filter((e) => e.isDirectory() && ID.test(e.name))
.filter((e) => fs.existsSync(path.join(loader.dir(), e.name, 'module.json')))
.map((e) => e.name)
.sort()
}
/**
* The absolute path of a module's `purge.sql`, or null.
*
@@ -348,6 +370,25 @@ async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
)
}
// One module per site (org lead, 2026-09-23). A site is one game, and the
// contract has singletons that assume it: `registerTeamProvider` holds ONE
// value per deployment, and a second module registering one fails its whole
// load — with modules loaded alphabetically, installing `rust` beside `uo`
// would have taken `uo` down, not `rust`. So an install is an UPGRADE of the
// module already here, or it is refused before a byte is downloaded.
//
// Refused here rather than in the admin controller so the declared module set
// (modules/declared.js) gets the same answer: an environment naming two
// modules installs the first and is told why the second was not.
const others = installedIds().filter((id) => id !== manifest.id)
if (others.length) {
throw new InstallError(
`this site already runs the module "${others.join('", "')}", and a site runs one module. ` +
`Upgrade it with its own release, or remove it before installing "${manifest.id}".`,
{ status: 409 },
)
}
const target = moduleDir(manifest.id)
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
const tarball = path.join(scratch, 'bundle.tar.gz')
@@ -444,6 +485,7 @@ module.exports = {
removeDir,
moduleDir,
isInstalled,
installedIds,
purgeFile,
MAX_MANIFEST_BYTES,
MAX_ARTIFACT_BYTES,

View File

@@ -261,6 +261,27 @@ function buildCtx(id, moduleRoot) {
(err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) },
)
},
// MODULE_API 1.11.0 (Rust PLAN_FIXES D183). The game ended one of this
// module's ledgered resources at its own deadline — a Rust zone erased when
// its time was up. Recorded as `expired`: terminal, never given back, and a
// different sentence on the console from `orphaned`, which is reconcile's
// word for a thing that vanished with nobody asking.
//
// `id` bound, fire-and-forget and returns undefined, all three for
// `reconcile`'s reasons directly above. A `{ kind, ref }` that names no
// live row is not an error: a module hears expiries of things no run ever
// ledgered, and telling it so would be noise it can do nothing with.
expired: (resource) => {
// eslint-disable-next-line global-require
require('../events/cleanup')
.expireResource(id, resource || {})
.then(
(summary) => {
if (summary && summary.expired) log.info('event resource expired', { module: id, ...summary })
},
(err) => { log.error('ctx.events.expired failed', { module: id, message: err.message }) },
)
},
},
// The in-app sink (§5.1) — a module writing the inbox directly, without a
// rule. Live from Phase 7; it threw until the `user_notifications` table

View File

@@ -9,6 +9,18 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.11.0 — `ctx.events.expired({ kind, ref })`, and `expired` as a resource
// ledger status (docs/website/EVENTS.md §L; Rust PLAN_FIXES D183). A game that
// ends something at its own deadline — a Rust zone erased when its time is up —
// could until now reach core only through `ctx.events.reconcile()`, which files
// it `orphaned`: amber, "it vanished", and still claimable for a revert. The new
// call files it as the plan working: terminal, green, never taken back.
//
// Additions only, so minor, and checked against the one module already built on
// 1.10.0: Module-uo never calls it, reads no ledger status, and every existing
// status keeps its meaning — `expired` joins neither `HELD` nor `UNRESOLVED`, so
// the sweep, the manual retry and `cleanup_status` see exactly what they saw.
// 1.10.0 — the event contract opens to modules: `api.registerEventActions`,
// `api.registerEventBudgets`, `api.registerEventLeases` and
// `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md
@@ -155,6 +167,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.10.0'
const MODULE_API_VERSION = '1.11.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -237,7 +237,7 @@ eventsRouter.get(
'/runs/:runId',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -41,11 +41,12 @@ modulesRouter.post(
'/',
// #swagger.tags = ['Admin · Modules']
// #swagger.summary = 'Install or upgrade a module from a release install-manifest URL'
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.'
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string", description: "https URL of the release install manifest, on an allowed host" } } } } } } */
/* #swagger.responses[201] = { description: 'Installed — restart to mount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'The URL, the manifest, the hash or the archive was refused', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'A different module is already installed; a site runs one module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[502] = { description: 'The source host could not be reached or answered badly', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('url').isString().trim().isLength({ min: 1, max: 2048 }),

View File

@@ -4271,7 +4271,7 @@
"Admin · Events"
],
"summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.",
"description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.",
"parameters": [
{
"name": "runId",
@@ -7211,7 +7211,7 @@
"Admin · Modules"
],
"summary": "Install or upgrade a module from a release install-manifest URL",
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.",
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.",
"responses": {
"201": {
"description": "Installed — restart to mount it",
@@ -7234,6 +7234,16 @@
}
}
},
"409": {
"description": "A different module is already installed; a site runs one module",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
@@ -26295,6 +26305,23 @@
}
}
},
"runId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3692
},
"description": {
"type": "string",
"example": "Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it."
}
}
},
"title": {
"type": "object",
"properties": {

View File

@@ -1247,6 +1247,12 @@ const doc = {
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
properties: {
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
runId: {
type: 'integer',
example: 3692,
description:
'Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it.',
},
title: { type: 'string', example: 'The Yew Invasion' },
slug: { type: 'string', example: 'the-yew-invasion' },
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },

View File

@@ -101,6 +101,7 @@ beforeEach(() => {
...new Set([...store.rows.values()].filter((r) => ['pending', 'confirmed'].includes(r.status)).map((r) => r.owner_module)),
]
resourcesDb.runsNeedingCleanup = async () => store.candidates || []
resourcesDb.finishedRunsWithNothingLeft = async () => store.nothingLeft || []
runsDb.setCleanupStatus = async (id, to, from = null) => {
if (from && !from.includes(store.cleanupStatus)) return false
@@ -618,3 +619,130 @@ test('reconcileAll asks every module that owns a live row', async () => {
// orphaned by a module that is not there to be asked.
assert.equal(out.other.unanswered, 1)
})
// ── Expiry (MODULE_API 1.11.0, Rust PLAN_FIXES D183) ─────────────────────────
//
// A game that ends something at its own deadline — a Rust zone erased when its
// time is up — says so through `ctx.events.expired`. The properties:
//
// • **expired is terminal**: the sweep never tries to give it back, and it is
// not one of the statuses that makes a run's cleanup unfinished
// • **it is not orphaned**, and it wins over orphaned: a reconcile that ran
// before the module heard the expiry filed the row as vanished
// • **a module closes only its own rows**, and only the target it named
// • **a revert already in flight is left to finish** — it finds nothing, which
// §L calls a success, and settles as `reverted` by itself
// • **a finished run whose last row this was is complete**, even one the sweep
// had given up on as `incomplete`
function stubExpiry() {
resourcesDb.expirableByTarget = async (owner, kind, ref) =>
[...store.rows.values()]
.filter((r) => r.owner_module === owner && r.kind === kind && r.ref === ref)
.filter((r) => ['pending', 'confirmed', 'orphaned'].includes(r.status))
.map((r) => ({ ...r }))
resourcesDb.markExpired = async (id, detail = null) => {
const r = store.rows.get(id)
if (!r || !['pending', 'confirmed', 'orphaned'].includes(r.status)) return false
Object.assign(r, { status: 'expired', last_error: detail })
return true
}
store.run = { ...RUN }
runsDb.getById = async (id) => (id === RUN.id ? store.run : null)
}
test('an expired resource is terminal: the sweep never tries to give it back', async () => {
stubExpiry()
let reverts = 0
registerAction({ revert: async () => { reverts += 1; return { ok: true } } })
addStep(1, 'demo.spawn')
addResource({ kind: 'zone', ref: '3:rg-7-1' })
assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: '3:rg-7-1' }), { expired: 1 })
const row = [...store.rows.values()][0]
assert.equal(row.status, 'expired')
assert.ok(store.log.some((e) => e.kind === 'resource.expired' && e.detail.resource === 'zone:3:rg-7-1'))
const summary = await cleanup.cleanupRun(RUN)
assert.equal(summary.attempted, 0)
assert.equal(reverts, 0)
assert.equal(row.status, 'expired')
assert.equal(store.cleanupStatus, 'complete')
})
test('an expiry wins over orphaned, and leaves a revert in flight alone', async () => {
stubExpiry()
addResource({ kind: 'zone', ref: 'a', status: 'orphaned' })
addResource({ kind: 'zone', ref: 'b', status: 'reverting' })
addResource({ kind: 'zone', ref: 'c', status: 'reverted' })
for (const ref of ['a', 'b', 'c']) await cleanup.expireResource('demo', { kind: 'zone', ref })
const status = (ref) => [...store.rows.values()].find((r) => r.ref === ref).status
assert.equal(status('a'), 'expired')
assert.equal(status('b'), 'reverting')
assert.equal(status('c'), 'reverted')
})
test('a module closes only its own rows, and only the target it named', async () => {
stubExpiry()
addResource({ owner_module: 'demo', kind: 'zone', ref: 'x' })
addResource({ owner_module: 'other', kind: 'zone', ref: 'x' })
addResource({ owner_module: 'demo', kind: 'npc', ref: 'x' })
assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' }), { expired: 1 })
const rows = [...store.rows.values()]
assert.deepEqual(rows.map((r) => r.status), ['expired', 'confirmed', 'confirmed'])
})
test('an expiry nobody ledgered, or a malformed one, is not an error', async () => {
stubExpiry()
addResource({ kind: 'zone', ref: 'x' })
for (const bad of [undefined, {}, { kind: 'zone' }, { ref: 'x' }, { kind: 1, ref: 'x' }, { kind: 'zone', ref: 'y' },
{ kind: 'zone', ref: 'r'.repeat(191) }]) {
assert.deepEqual(await cleanup.expireResource('demo', bad), { expired: 0 }, JSON.stringify(bad))
}
assert.equal([...store.rows.values()][0].status, 'confirmed')
assert.equal(store.log.length, 0)
})
test('the last row of a finished run expiring completes an incomplete cleanup', async () => {
stubExpiry()
addResource({ kind: 'zone', ref: 'x' })
store.cleanupStatus = 'incomplete'
await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' })
assert.equal(store.cleanupStatus, 'complete')
})
test('a run still in flight keeps its cleanup status, and so does one with rows left', async () => {
stubExpiry()
addResource({ kind: 'zone', ref: 'x' })
store.run = { ...RUN, status: 'running' }
store.cleanupStatus = 'pending'
await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' })
assert.equal(store.cleanupStatus, 'pending')
store.rows.clear()
addResource({ kind: 'zone', ref: 'y' })
addResource({ kind: 'zone', ref: 'z' })
store.run = { ...RUN }
store.cleanupStatus = 'incomplete'
await cleanup.expireResource('demo', { kind: 'zone', ref: 'y' })
assert.equal(store.cleanupStatus, 'incomplete')
})
test('a run whose last resource expired while it ran is settled complete when it ends', async () => {
// The step-2 walk's run 46: the zone expired while the run was still going, so
// expireResource left its status to the terminal path — and the sweep's scan,
// which joins on an unresolved row, would never have selected it. `pending`
// for ever, on a ledger with nothing left in it.
store.cleanupStatus = 'pending'
store.candidates = []
store.nothingLeft = [RUN.id]
assert.equal(await cleanup.sweep(), 1)
assert.equal(store.cleanupStatus, 'complete')
// And never over an incomplete one — that is a human's to clear.
store.cleanupStatus = 'incomplete'
await cleanup.sweep()
assert.equal(store.cleanupStatus, 'incomplete')
})

View File

@@ -38,6 +38,7 @@ const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const semver = require('../src/modules/semver')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
@@ -99,13 +100,19 @@ beforeEach(() => {
// ── The seam is open ───────────────────────────────────────────────────────
test('the version a module declares against is 1.10.0', () => {
test('the version a module declares against satisfies ^1.10.0', () => {
// Not decoration. `coreApi: "^1.10.0"` on every module below is what makes
// these tests fail loudly rather than quietly if the bump is ever reverted —
// the loader would refuse the manifest and every assertion would become "the
// module did not register", which is the same failure the seam closing would
// produce. Asserting the number here says which of the two it was.
assert.equal(MODULE_API_VERSION, '1.10.0')
// produce. Asserting the range here says which of the two it was.
//
// A range, not the number, since 1.11.0 (`ctx.events.expired`, Rust
// PLAN_FIXES D183): an additive bump must keep every module written against
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
// assertion that says it does. The number itself is pinned in version.js.
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
assert.equal(MODULE_API_VERSION, '1.11.0')
})
test('a module registers actions, budgets, leases and option sources', () => {

View File

@@ -164,10 +164,32 @@ test('a calendar entry carries no operational field at all', async () => {
// The whole security property of this file, asserted positively: the entry has
// exactly these keys and gaining one is a deliberate act.
assert.deepEqual(Object.keys(entry).sort(), [
'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
'kind', 'live', 'runId', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone',
'title',
])
})
test('a run entry names its run, and a projection names none', async () => {
// Rust phase 15, D125: a map marker carries core's run id, and the app finds
// the event it belongs to from this calendar. The id is the one the event page
// already publishes on each occurrence.
store.definitions[0].spec = {
...SPEC,
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
}
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-09-15', now: NOW })
const runs = result.entries.filter((e) => e.kind === 'run')
const projected = result.entries.filter((e) => e.kind === 'projected')
assert.equal(runs.length, 1)
assert.equal(runs[0].runId, store.runs[0].id)
assert.ok(projected.length > 0, 'the weekly schedule must forecast past the one run')
for (const entry of projected) assert.equal('runId' in entry, false)
const page = await publicModel.event('the-yew-invasion')
const occurrences = [page.event.current, page.event.next, ...page.event.upcoming, ...page.event.past]
assert.ok(occurrences.some((o) => o && o.runId === runs[0].runId))
})
test('the default window reaches back as well as forward', async () => {
// §I: this route is "upcoming, live and recent". The default used to start at
// `now`, which left no room for the third word — an event that finished an hour

View File

@@ -395,6 +395,79 @@ test('a failed upgrade leaves the previous version in place', async () => {
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
})
// ── One module per site ────────────────────────────────────────────────────
/** A second, different module's manifest and artifact, served beside the first. */
function otherModuleRoutes(id = 'rust') {
const tarball = bundle({ id })
const artifact = `https://releases.example.com/mod/${id}-1.0.0.tar.gz`
const url = `https://releases.example.com/mod/${id}-1.0.0.json`
return {
url,
routes: {
[url]: manifestFor(tarball, { id, name: id, artifact: `${id}-1.0.0.tar.gz`, url: artifact }),
[artifact]: tarball,
},
artifact,
}
}
test('a second, different module is refused before anything is downloaded', async () => {
const first = goodRoutes()
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
const other = otherModuleRoutes('rust')
const fetchImpl = fakeFetch(other.routes)
await assert.rejects(
() => install.install({ url: other.url, hosts: HOSTS, fetchImpl }),
(err) => {
assert.equal(err.name, 'InstallError')
// 409: nothing is wrong with the URL; the SITE is not in a state to take it.
assert.equal(err.status, 409)
assert.match(err.message, /already runs the module "uo"/)
assert.match(err.message, /remove it before installing "rust"/)
return true
},
)
// Refused on the manifest alone: the artifact was never fetched, and the
// volume holds exactly what it held before.
assert.ok(!fetchImpl.seen.includes(other.artifact), 'the artifact was not downloaded')
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
})
test('the same module is still an upgrade, and removing it frees the site for another', async () => {
const first = goodRoutes()
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
// An upgrade of what is installed is exactly what the rule allows.
const second = goodRoutes({ version: '2.0.0', manifest: { version: '2.0.0' } })
second.routes[MANIFEST_URL] = manifestFor(second.tarball, { version: '2.0.0' })
const upgraded = await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(second.routes) })
assert.equal(upgraded.replaced, true)
// And once it is gone, the site takes a different one.
await install.removeDir('uo')
const other = otherModuleRoutes('rust')
const result = await install.install({ url: other.url, hosts: HOSTS, fetchImpl: fakeFetch(other.routes) })
assert.equal(result.id, 'rust')
assert.deepEqual(install.installedIds(), ['rust'])
})
test('what counts as installed is what the loader would scan', () => {
// A real module, an install's scratch directory, a swap's aside copy and a
// directory with no module.json. Only the first is a module.
fs.mkdirSync(path.join(tmpRoot, 'uo'))
fs.writeFileSync(path.join(tmpRoot, 'uo', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, '.install-rust-abc'))
fs.writeFileSync(path.join(tmpRoot, '.install-rust-abc', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, 'uo.replaced-123'))
fs.writeFileSync(path.join(tmpRoot, 'uo.replaced-123', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, 'notes'))
assert.deepEqual(install.installedIds(), ['uo'])
})
// ── The volume ─────────────────────────────────────────────────────────────
test('moduleDir refuses an id that is not one', () => {

View File

@@ -495,6 +495,7 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({
keys: Object.keys(ctx).sort(),
middleware: Object.keys(ctx.middleware).sort(),
events: Object.keys(ctx.events).sort(),
moduleId: ctx.moduleId,
mutable,
}))
@@ -530,10 +531,42 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
assert.deepEqual(probe.middleware, [
'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate',
])
// API 1.10.0 gave `events` its `reconcile`, and 1.11.0 its `expired` (Rust
// PLAN_FIXES D183): the game ended a ledgered resource at its own deadline.
assert.deepEqual(probe.events, ['emit', 'expired', 'reconcile'])
assert.equal(probe.moduleId, 'probe')
assert.equal(probe.mutable, false, 'ctx members must be frozen')
})
test("ctx.events.expired closes the calling module's rows, whatever it passes", async () => {
// The owner is bound by the loader, never read from the arguments — the same
// rule `reconcile` keeps. Without it a module could mark another module's
// resources expired and take them off the teardown list.
const cleanup = require('../src/events/cleanup')
const original = cleanup.expireResource
const calls = []
cleanup.expireResource = async (owner, resource) => {
calls.push({ owner, resource })
return { expired: 1 }
}
try {
writeModule('zoner', {
server: `module.exports = (ctx) => {
const result = ctx.events.expired({ kind: 'zone', ref: '3:rg-1', owner: 'someone-else', owner_module: 'x' })
if (result !== undefined) throw new Error('expired must return undefined')
}`,
})
assert.equal(stateOf(freshLoader(tmpRoot), 'zoner').state, 'registered')
await new Promise((resolve) => setImmediate(resolve))
assert.equal(calls.length, 1)
assert.equal(calls[0].owner, 'zoner')
assert.equal(calls[0].resource.kind, 'zone')
assert.equal(calls[0].resource.ref, '3:rg-1')
} finally {
cleanup.expireResource = original
}
})
// ── Lifecycle hooks ────────────────────────────────────────────────────────
test('a lifecycle hook must be a function, and may be registered once', () => {