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
148 lines
8.1 KiB
JavaScript
148 lines
8.1 KiB
JavaScript
// ── event_run_log — SQL only ───────────────────────────────────────────────
|
|
//
|
|
// EVENTS.md § Observability. "Why didn't phase 3 start?" must be a query, and
|
|
// `activity_log.detail` is TEXT and unqueryable, which is why this table exists
|
|
// beside the audit log rather than instead of it. Both are written: the audit of
|
|
// WHO published WHAT goes to `activity_log`, the diagnosis goes here.
|
|
//
|
|
// **`kind` is a closed set enforced here rather than an ENUM in the DDL.** The
|
|
// set grows with almost every later phase — conditions in Phase 5, cap draws in
|
|
// Phase 6, ledger movements in Phase 8 — and an ENUM change is a table alter
|
|
// this project has no migration system for. A constant in a file is the same
|
|
// guarantee with a cheaper hinge.
|
|
|
|
const log = require('../../utils/logger')('events')
|
|
const { query } = require('../../utils/db')
|
|
const { parseJson } = require('./eventJson')
|
|
|
|
// Phase 1's kinds. Later phases append; nothing here is ever renamed, because a
|
|
// stored row would then name a kind no reader knows.
|
|
const KINDS = [
|
|
'run.created', // an occurrence was materialised
|
|
'run.status', // a status transition, with from/to
|
|
'phase.entered', // a phase's steps were materialised
|
|
'step.status', // a step transition, with the module's answer
|
|
'note', // a human action taken from the admin surface
|
|
// Phase 2's, all five of them answers to a question an operator asks out
|
|
// loud. `run.blocked` in particular is the whole reason this table exists
|
|
// rather than a server log line: "it did not start because run 37 holds
|
|
// invasion:Yew" is a fact with two run ids in it, and it has to be
|
|
// queryable from the run that did NOT start.
|
|
'run.blocked', // an occurrence held off: another run has its concurrency key
|
|
'run.health', // a health change, which is not a status change
|
|
'step.retry', // a step failed transiently and will be attempted again
|
|
'step.parked', // a step is waiting on a human and nothing is holding it
|
|
'phase.completed', // every step of a phase reached a terminal status
|
|
// Phase 5's four. `condition.evaluated` is written for BOTH outcomes (§
|
|
// Observability), and the non-matching one is the more valuable of the two on
|
|
// the night: "the boss did spawn, in Britain" and "no boss has spawned" are
|
|
// different answers to the same question and look identical without it.
|
|
'phase.gate', // a phase opened an advance gate, with what it waits for
|
|
'condition.evaluated', // a firing was tested against a gate, matched or not
|
|
'phase.advanced', // a gate opened: on a firing, on its deadline, or forced
|
|
// Phase 6's three. `step.refused` is the one worth naming separately from
|
|
// `step.status`: a refusal is not a failure, and an operator reading a run that
|
|
// stopped needs to see at a glance that nothing is broken -- the deployment
|
|
// simply does not permit what the author asked for.
|
|
'run.budget', // the caps this run was seeded with, and which switch set each
|
|
'step.refused', // a step was not permitted: disabled, or over a cap
|
|
'version.verified', // a dry run passed against a version, unlocking scheduled starts
|
|
// Phase 8's six, and every one of them is an answer to "what did this event
|
|
// leave behind". `resource.recorded` is written at the ANSWER rather than at
|
|
// the placeholder, because a placeholder is a promise and the operator's
|
|
// 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
|
|
'cleanup.retry', // a human cleared the attempt counter and asked again
|
|
// Phase 10's four: the integrations. `announcement.emitted` is a line about
|
|
// what the run SAID happened, not about who was told -- the engagement engine
|
|
// owns that decision and logs its own, and a run log that claimed to know how
|
|
// many mails went out would be reporting a decision it does not make.
|
|
'participants.recorded', // a step reported who took part, and they are recorded
|
|
'results.published', // the results table was ranked and stamped
|
|
'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
|
|
'announcement.enqueued', // a post was linked to this run and queued on the legs
|
|
// Phase 15's one, and it is the only kind whose payload core does not compose.
|
|
// A module may answer a success envelope with a `detail` object; it is bounded
|
|
// and sanitised at the dispatcher and written here verbatim beside the action
|
|
// id. Nothing reads a key out of it — it exists because a module knows things
|
|
// about its own verb that core cannot compute and had no other way to say.
|
|
'step.detail', // a module's own account of what a successful step did
|
|
]
|
|
|
|
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
|
|
|
|
const listForRun = async (runId, { limit = 500 } = {}) => {
|
|
const n = Math.min(Math.max(Number(limit) || 500, 1), 2000)
|
|
return (
|
|
await query(`SELECT * FROM event_run_log WHERE run_id = ? ORDER BY at DESC, id DESC LIMIT ${n}`, [
|
|
runId,
|
|
])
|
|
).map(hydrate)
|
|
}
|
|
|
|
/**
|
|
* Write one line. **Never throws.**
|
|
*
|
|
* The diagnostic log is what an operator reads when something has already gone
|
|
* wrong, so a failure to write it must not become a second failure on top of the
|
|
* first — a runner that aborted a run because it could not record why would be
|
|
* the worst possible reading of "observability". The same posture
|
|
* `uoLinkClient.js` takes: answer, do not throw.
|
|
*/
|
|
async function write({ runId, stepId = null, kind, phase = null, detail = null }) {
|
|
if (!KINDS.includes(kind)) {
|
|
// A programming error, not an operational one, and it is louder than a
|
|
// silent drop for exactly that reason.
|
|
log.warn('event run log: unknown kind', { kind, runId })
|
|
return false
|
|
}
|
|
try {
|
|
await query(
|
|
'INSERT INTO event_run_log (run_id, step_id, kind, phase, detail) VALUES (?, ?, ?, ?, ?)',
|
|
[runId, stepId, kind, phase, detail === null ? null : JSON.stringify(detail)],
|
|
)
|
|
return true
|
|
} catch (err) {
|
|
log.error('event run log write failed', { runId, kind, message: err.message })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete log lines belonging to runs that are both TERMINAL and older than
|
|
* `before`, a bounded number at a time.
|
|
*
|
|
* The schema comment beside `idx_evlog_at` parked this sweep here, and it is the
|
|
* rule Engagement Phase 14 arrived at applied to a second high-cardinality table:
|
|
* **only terminal rows are eligible.** A run still in flight keeps every line it
|
|
* has, however old — the log's whole job is answering "why didn't phase 3 start?"
|
|
* about a run that is, right now, not starting phase 3, and a horizon that could
|
|
* reach a live run would delete the answer while the question was still open.
|
|
*
|
|
* `LIMIT` makes one call a bounded amount of work rather than a table-sized
|
|
* transaction; the timer runs again and takes the next slice. The join is on the
|
|
* run's terminal status rather than on a precomputed id list so that a run which
|
|
* reached a terminal state between the two would not be missed.
|
|
*/
|
|
const pruneTerminal = async (before, limit = 5000) => {
|
|
const n = Math.min(Math.max(Number(limit) || 5000, 1), 50_000)
|
|
const result = await query(
|
|
`DELETE l FROM event_run_log l
|
|
JOIN event_runs r ON r.id = l.run_id
|
|
WHERE r.status IN ('completed','cancelled','failed','missed')
|
|
AND COALESCE(r.ended_at, r.updated_at) < ?
|
|
LIMIT ${n}`,
|
|
[before],
|
|
)
|
|
return Number(result?.affectedRows || 0)
|
|
}
|
|
|
|
module.exports = { KINDS, listForRun, write, pruneTerminal }
|