// ── 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 '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 ] 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 }