Files
website/server/src/model/events/eventRunLog.db.js
wtclaude cc1f49af29
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
feat(events): an expired ledger status and ctx.events.expired (MODULE_API 1.11.0)
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

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 }