Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN. One table, one core action, one route, one body field, and two members added to MODULE_API 1.10.0 in place. The safety property the whole world-write half depends on: core now remembers what a run changed in the world, and gives it back on every terminal path. Four decisions settled by the org lead on 2026-09-03, all as recommended: - A lease is acquired by a new CORE action, `core.lease`. Section F puts the duration bound and the two-events-one-target conflict check on core's side of the seam, and a lease verb per module would be both re-implemented once per module, advisory everywhere. - Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A spawn's ref does not exist until the module answers, so what core writes before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never comes it stands, and cleanup calls revert() with the key and no resources -- which is why section F's revert takes the key at all. - Cleanup is one sweep over the ledger, not synthetic step rows. The step-shaped version costs a second retry counter beside `revert_attempts`. - `reconcile` is declared here and TRIGGERED BY THE MODULE, through `ctx.events.reconcile()`. Core has no concept of the game being up, so it cannot decide when to ask; it asks once at its own boot. MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`; while it is on `edge` it is amended in place, so the whole module contract reaches an author as one version they read once. Verify - `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the pre-existing engagementManifest CRLF failure, in a file this branch does not touch (`edge` before: 1950/1876/73/1). +75 tests. - The unique key was proved against a REAL MariaDB, because nothing else can prove it: whether multiple NULLs collide in a unique index, whether a STORED generated column is recomputed on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the server. eventRunnerSql.test.js gained 16 tests; 65 pass against the container. The real schema.sql was applied to a fresh database and to an existing one. - Client: 362 pass, and it builds. routes:manifest and swagger -- one route added, none moved. The live walk found three defects, and two of them are the phase's real finding Driven by a throwaway `rig` module in website/modules/, deleted before commit. 1. A lease was never given back at all. `core.lease` reserves its own ledger row, so it never went through the ledger's dirty-marking, so a run holding only a lease kept `cleanup_status = 'not_required'` and the cleanup leg -- which selected on `pending` -- never looked at it. 2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing sweep moved the run to `incomplete`, which took it out of the leg's own scan for ever. The test covering the bound asserted `<= 3` and was satisfied by 1: a bound has two halves, and a test that only asserts the ceiling passes against a floor. 3. The first fix for (2) made the console lie. Spending every row's `revert_attempts` was a tidy way to take a `cleanup: false` run out of a counter-bounded scan, and the run page then rendered "3 attempts" beside resources nothing had ever tried. Found by opening the page. Both (1) and (2) are the same mistake: deriving "is there anything to do" from a summary column instead of from the rows. Neither was visible to a unit test, because a test that calls the sweep directly never asks what would have selected the run. The two properties that need the process to die were walked as the plan asks. With the module's perform() hanging, the placeholder existed while the dispatch was in flight and nothing was named; after taskkill and a restart the reclaim re-dispatched the same idempotency key, the retry re-used its own placeholder, and everything was given back. Then, with the module reporting one of two resources as no longer in force, the boot-time reconcile marked the other `orphaned` -- never `reverted`. This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json. Co-Authored-By: Claude <noreply@anthropic.com>
131 lines
6.7 KiB
JavaScript
131 lines
6.7 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
|
|
'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 }
|