fix(events): carry a module's own account of a successful step
`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.
`module-uo` believed it, twice, since Phase 12b:
* `uo.item.grant` answers `{ granted, missed, why }`
* `uo.world.save` answers `{ started: true }`
The grant is the one that matters. A grant reaches the players a run's
participation ledger holds, and **which of them missed out is knowable only to
the module and reported nowhere else** — so an operator saw a step marked
`done` and never learned four of twelve got nothing.
Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.
## What this adds
`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.
**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.
* objects only — the column is JSON and the console renders keys, so a bare
string has nothing to render under, and core inventing a key would be core
interpreting it after all;
* 4KB of serialised JSON, dropped rather than truncated, because half a JSON
object is not a JSON object;
* unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
runner would make the log INSERT throw, inside the one write documented
never to;
* re-parsed rather than passed through, so core holds no live reference into
a module's object;
* **anything wrong with it is dropped and logged, never a failure.** A step
that did what it was asked must not be re-run because its module's
commentary was malformed: that is a world write repeated for a log line.
The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.
## The renderer, which is half the fix
`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.
uo.item.grant — granted: 8, missed: 4, why: bank full, offline
uo.world.save — started: true
**`module-uo` needs no change**: the code it already shipped starts working.
MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -692,9 +692,46 @@ const KIND_WORDS = {
|
||||
'step.refused': 'Refused',
|
||||
'run.budget': 'Caps',
|
||||
'version.verified': 'Dry run passed',
|
||||
// Phase 15. "Reported" rather than "Detail": the line is the module talking
|
||||
// about its own verb, and every other word here names something core did.
|
||||
'step.detail': 'Step reported',
|
||||
note: 'Note',
|
||||
}
|
||||
|
||||
// How deep and how long a module's own `detail` value is allowed to render.
|
||||
// The dispatcher already caps the whole object at 4KB, so this is about a line
|
||||
// staying a line — an operator scanning a run's log should not have one row
|
||||
// wrap eight times because a module answered with an array of forty names.
|
||||
const DETAIL_LIST_SHOWN = 5
|
||||
const DETAIL_TEXT_MAX = 80
|
||||
|
||||
/**
|
||||
* One value out of a module's `detail`, as text.
|
||||
*
|
||||
* **Core does not interpret these keys and neither does this.** A module wrote
|
||||
* the object; the console shows it. That is the whole reason the renderer is
|
||||
* generic rather than a switch — a switch would be core learning a module's
|
||||
* vocabulary, which is the thing the module system exists to prevent.
|
||||
*/
|
||||
function detailValue(value) {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (Array.isArray(value)) {
|
||||
const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ')
|
||||
return value.length > DETAIL_LIST_SHOWN
|
||||
? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more`
|
||||
: shown
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// A nested object is rendered by its keys rather than as JSON: an operator
|
||||
// reading a log wants "granted: 8, missed: 4", not a brace.
|
||||
return Object.entries(value)
|
||||
.map(([k, v]) => `${k} ${detailValue(v)}`)
|
||||
.join(', ')
|
||||
}
|
||||
const text = String(value)
|
||||
return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}…` : text
|
||||
}
|
||||
|
||||
export const logKindWord = (kind) => KIND_WORDS[kind] || kind
|
||||
|
||||
/**
|
||||
@@ -758,6 +795,22 @@ export function describeLogLine(line) {
|
||||
.join(', ') || 'no caps apply to this run'
|
||||
case 'version.verified':
|
||||
return `Version ${d.version} passed its dry run — scheduled occurrences may start`
|
||||
// Phase 15. The one line whose body core did not compose: a module may answer
|
||||
// a successful step with a `detail` object, and this renders whatever keys it
|
||||
// put there. `action` is core's own and is pulled out to lead the sentence;
|
||||
// everything after it is the module's.
|
||||
//
|
||||
// **Without this case the row would render as the literal string
|
||||
// "step.detail"**, because the default below is a kind word and not a
|
||||
// sentence — which would be the reporting channel existing and showing
|
||||
// nothing, the exact failure it was built to fix.
|
||||
case 'step.detail': {
|
||||
const { action, ...rest } = d
|
||||
const body = Object.entries(rest)
|
||||
.map(([key, value]) => `${key}: ${detailValue(value)}`)
|
||||
.join(', ')
|
||||
return body ? `${action || 'A step'} — ${body}` : `${action || 'A step'} reported nothing`
|
||||
}
|
||||
default:
|
||||
return logKindWord(line?.kind)
|
||||
}
|
||||
|
||||
@@ -347,6 +347,54 @@ test('the log lines a run produces all render as something', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── The one log line core did not compose (Phase 15) ──────────────────────
|
||||
|
||||
test('a module detail line renders the module keys, not the kind id', () => {
|
||||
// The failure this guards is subtle and total: `step.detail` falling to the
|
||||
// default renders the literal string "step.detail", which is the reporting
|
||||
// channel existing and showing nothing — exactly what it was built to fix.
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'uo.item.grant', granted: 8, missed: 4, why: ['bank full', 'offline'] },
|
||||
})
|
||||
|
||||
assert.ok(!text.includes('step.detail'), `the kind id leaked into the sentence: ${text}`)
|
||||
assert.match(text, /uo\.item\.grant/)
|
||||
assert.match(text, /granted: 8/)
|
||||
assert.match(text, /missed: 4/)
|
||||
assert.match(text, /bank full/)
|
||||
})
|
||||
|
||||
test('a module detail is rendered generically, whatever a module puts in it', () => {
|
||||
// Core does not interpret these keys and neither does the renderer — a switch
|
||||
// here would be the browser learning one module vocabulary, which is the thing
|
||||
// the module system exists to prevent. So an unfamiliar shape still reads.
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'rust.wipe.announce', servers: { eu: 3, us: 1 }, dryRun: false, at: null },
|
||||
})
|
||||
assert.ok(!text.includes('undefined'), text)
|
||||
assert.ok(!text.includes('[object Object]'), `a nested object rendered as a brace: ${text}`)
|
||||
assert.match(text, /eu 3/)
|
||||
assert.match(text, /dryRun: false/, 'false is a value, not an absence')
|
||||
})
|
||||
|
||||
test('a long module detail stays one line', () => {
|
||||
const many = Array.from({ length: 40 }, (_, i) => `player-${i}`)
|
||||
const text = describeLogLine({
|
||||
kind: 'step.detail',
|
||||
detail: { action: 'uo.item.grant', missed: many, note: 'x'.repeat(500) },
|
||||
})
|
||||
assert.match(text, /and 35 more/)
|
||||
assert.ok(text.length < 300, `one row should not wrap eight times: ${text.length} chars`)
|
||||
})
|
||||
|
||||
test('a module detail with nothing in it still reads as a sentence', () => {
|
||||
const text = describeLogLine({ kind: 'step.detail', detail: { action: 'uo.world.save' } })
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(!text.includes('undefined'), text)
|
||||
})
|
||||
|
||||
test('every run status has a word, and an unknown one falls through rather than blanking', () => {
|
||||
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
|
||||
assert.ok(runStatusWord(s).length > 0)
|
||||
|
||||
Reference in New Issue
Block a user