feat(events): enablement, per-run caps and mayInvoke (Phase 6)
Two new tables — event_action_settings (the deployment switchboard) and event_run_budget (what a run has spent and the most it may) — plus verified_at and verified_by on event_versions. The whole authorisation decision moves behind one function, events/authorize.js: role, enablement, cap, and the shard's own switch named as the layer core deliberately does not duplicate. Three routes, none moved: GET/PUT /admin/events/actions (admin in both directions) and POST /admin/events/:id/verify (admin, editor — a dry run dispatches nothing). Four decisions, settled by the org lead 2026-09-03: - The default-off line falls between inspect and change, not between notify and inspect. Read literally, §K shipped core.wait disabled. The same line is the role floor. - The tightest cap wins where two actions spend one dimension, pinned into the run at creation with the action it came from. - A refusal follows the step's on_failure and takes health to degraded — its own status and its own log kind, because a refusal is not an outage. - The verify gate is enforced for scheduled starts only: a human pressing Start now is the review the gate exists to require. Derived and flagged for review: a dry run fails rather than warns on a disabled action or an over-cap plan, and the unattended path does not re-check the starter's role. +111 tests (1921/1847/73/1 — the one failure pre-existing and environmental), including a 403 walk over the real router and two concurrent spends against one cap on a real MariaDB. The live walk found two defects, both fixed here: the run console route dropped the budget it was handed, and the role refusal used a plural verb over a one-item list. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
86
server/src/model/events/eventActionSettings.db.js
Normal file
86
server/src/model/events/eventActionSettings.db.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── event_action_settings — SQL only ───────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard:
|
||||
// one row per action an admin has an opinion about, and **nothing else in the
|
||||
// permission model beyond the role**.
|
||||
//
|
||||
// **A missing row is not "disabled".** It is "the default for this action's risk
|
||||
// class", and that default is computed in `eventActionSettings.model.js` from the
|
||||
// registry rather than stored here. The reason is structural: the registry is
|
||||
// assembled by `registerCore()` and by module `register()`, both of which run
|
||||
// under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md
|
||||
// §2.2), so a boot-time seed of one row per registered action would be precisely
|
||||
// the database write those two forbid. A deployment that never opens the
|
||||
// switchboard has no rows at all and behaves correctly.
|
||||
//
|
||||
// **Rows outlive their actions on purpose.** Uninstalling a module leaves its
|
||||
// settings standing, so re-installing restores the caps the operator chose
|
||||
// instead of silently resetting them to the default. The switchboard lists what
|
||||
// is registered *now*, so a stranded row is invisible until its action returns.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} }
|
||||
|
||||
/** Every stored row, action id first. Stranded rows included — the caller filters. */
|
||||
async function all() {
|
||||
const rows = await query(
|
||||
`SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username
|
||||
FROM event_action_settings s
|
||||
LEFT JOIN users u ON u.id = s.updated_by
|
||||
ORDER BY s.action_id`,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** One row, or null when the deployment has never had an opinion about this action. */
|
||||
async function get(actionId) {
|
||||
const rows = await query(
|
||||
`SELECT action_id, enabled, caps, updated_by, updated_at
|
||||
FROM event_action_settings WHERE action_id = ?`,
|
||||
[actionId],
|
||||
)
|
||||
return rows.length ? hydrate(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for a set of action ids, as a Map keyed by id.
|
||||
*
|
||||
* The shape the authorisation path wants: `mayInvoke` is asked about one action
|
||||
* at a time but the run start prices a whole version at once, and one query per
|
||||
* step of a twelve-step definition is twelve round trips to answer a question
|
||||
* about a table with one row per action in the process.
|
||||
*/
|
||||
async function byIds(actionIds) {
|
||||
const ids = [...new Set(actionIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT action_id, enabled, caps, updated_by, updated_at
|
||||
FROM event_action_settings
|
||||
WHERE action_id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
return new Map(rows.map((r) => [r.action_id, hydrate(r)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one action's switch and caps.
|
||||
*
|
||||
* An upsert rather than a read-then-write, for the ordinary reason: two admins on
|
||||
* the switchboard at once should leave one of their two opinions standing, not an
|
||||
* error and not a row that never appeared. There is no compare-and-set here
|
||||
* because there is nothing to race — this is configuration, and the value that
|
||||
* matters is the last one a human chose.
|
||||
*/
|
||||
async function put(actionId, { enabled, caps }, userId = null) {
|
||||
await query(
|
||||
`INSERT INTO event_action_settings (action_id, enabled, caps, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`,
|
||||
[actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId],
|
||||
)
|
||||
return get(actionId)
|
||||
}
|
||||
|
||||
module.exports = { all, get, byIds, put }
|
||||
@@ -16,9 +16,16 @@ const hydrate = (row) =>
|
||||
// `current_version` is joined rather than stored: the list screen shows "v3" and
|
||||
// the column that would hold it is a denormalisation of a row this query already
|
||||
// has to reach for the publish date anyway.
|
||||
//
|
||||
// `current_version_verified_at` rides along for the same reason and answers the
|
||||
// same kind of question (Phase 6). A `ready` definition whose version has never
|
||||
// been dry-run will not start on its schedule (§K), and the one place that fact
|
||||
// is worth saying is the screen its author is already looking at -- the
|
||||
// alternative is finding out on the Friday it did not run.
|
||||
const SELECT_LIST = `
|
||||
SELECT d.*, s.name AS series_name, s.slug AS series_slug,
|
||||
v.version AS current_version
|
||||
v.version AS current_version,
|
||||
v.verified_at AS current_version_verified_at
|
||||
FROM event_definitions d
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
LEFT JOIN event_versions v ON v.id = d.current_version_id
|
||||
|
||||
@@ -26,6 +26,9 @@ const runsDb = require('./eventRuns.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const seriesDb = require('./eventSeries.db')
|
||||
const spec = require('../../events/spec')
|
||||
const authorize = require('../../events/authorize')
|
||||
const verifier = require('../../events/verify')
|
||||
const registries = require('../../modules/registries')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
|
||||
@@ -162,9 +165,11 @@ async function validate(input, { existing = null } = {}) {
|
||||
}
|
||||
|
||||
/** Create a draft. */
|
||||
async function create(input, userId) {
|
||||
async function create(input, userId, { role = null } = {}) {
|
||||
const result = await validate(input)
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const id = await db.insert({ ...result.definition, created_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -177,7 +182,7 @@ async function create(input, userId) {
|
||||
* retired, and a retired definition that can still be edited is a definition
|
||||
* somebody will edit and then wonder why it never runs.
|
||||
*/
|
||||
async function save(id, input, userId) {
|
||||
async function save(id, input, userId, { role = null } = {}) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
@@ -185,6 +190,8 @@ async function save(id, input, userId) {
|
||||
}
|
||||
const result = await validate(input, { existing })
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
await db.update(id, { ...result.definition, updated_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -283,12 +290,113 @@ async function archive(id, userId) {
|
||||
return { ok: true, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The role floor on a spec's steps (§K, Phase 6).
|
||||
*
|
||||
* §K's table reads "any step whose action is above `notify` — `admin` only", and
|
||||
* the line falls between `inspect` and `change` for the reason the default-off
|
||||
* rule does (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
* nothing, and an editor who cannot author a step that WAITS has an authoring
|
||||
* role that cannot author.
|
||||
*
|
||||
* Checked at SAVE rather than only at publish, which is the difference between
|
||||
* telling an editor now and telling them after they have written twelve steps.
|
||||
* Publish re-checks anyway — it re-checks everything, against the registries as
|
||||
* they stand at that moment — because an action's risk class is a module's
|
||||
* declaration and a module can be upgraded between the two.
|
||||
*/
|
||||
function worldChangingSteps(specValue) {
|
||||
const out = []
|
||||
for (const phase of specValue?.phases || []) {
|
||||
for (const step of phase.steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (action && authorize.changesWorld(action)) out.push(action)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function checkRoleFloor(specValue, role) {
|
||||
if (!role || role === 'admin') return null
|
||||
const blocked = worldChangingSteps(specValue)
|
||||
if (!blocked.length) return null
|
||||
const names = [...new Set(blocked.map((a) => `"${a.label}"`))]
|
||||
// Agreement, because this sentence is read by the person it refuses. The list
|
||||
// is almost always one long -- an editor adds one world-changing step and is
|
||||
// stopped -- so `"Spawn creatures" change the world` is the case that shows,
|
||||
// and it reads as a bug in the sentence rather than a rule about the step.
|
||||
const one = names.length === 1
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
errors: [
|
||||
`${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run a definition, and record the pass when there is a version to record it
|
||||
* on (Phase 6).
|
||||
*
|
||||
* The target follows the definition's state: a `ready` definition is verified
|
||||
* against the version that would actually run, a draft against the working spec
|
||||
* the author is still holding. See `events/verify.js` for why that is one act at
|
||||
* two moments rather than two rules.
|
||||
*/
|
||||
async function verify(id, user) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] }
|
||||
}
|
||||
|
||||
const version =
|
||||
existing.state === 'ready' && existing.current_version_id
|
||||
? await versionsDb.getById(existing.current_version_id)
|
||||
: null
|
||||
const target = version?.spec || existing.spec
|
||||
if (!target?.phases?.length) {
|
||||
return { ok: false, status: 409, errors: ['this definition has no phases to verify'] }
|
||||
}
|
||||
|
||||
const report = await verifier.verifySpec(target, { user })
|
||||
|
||||
if (version && report.ok) {
|
||||
await versionsDb.markVerified(version.id, user?.id || null)
|
||||
// Written to every run already pinned to this version, because that is where
|
||||
// an operator asks the question: a scheduled occurrence that was being held
|
||||
// is now going to start, and the line saying why belongs on it.
|
||||
for (const run of await runsDb.listScheduledFor(id)) {
|
||||
if (Number(run.version_id) !== Number(version.id)) continue
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'version.verified',
|
||||
detail: { versionId: version.id, version: version.version, by: user?.id || null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
// Which spec was verified, said plainly, because the two answer different
|
||||
// questions and a report that did not say would be read as the other one.
|
||||
target: version ? 'version' : 'draft',
|
||||
versionId: version?.id || null,
|
||||
version: version?.version || null,
|
||||
recorded: Boolean(version && report.ok),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
create,
|
||||
save,
|
||||
publish,
|
||||
archive,
|
||||
verify,
|
||||
checkRoleFloor,
|
||||
isTimezone,
|
||||
MIN_GRACE_SECONDS,
|
||||
MAX_GRACE_SECONDS,
|
||||
|
||||
134
server/src/model/events/eventRunBudget.db.js
Normal file
134
server/src/model/events/eventRunBudget.db.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── event_run_budget — SQL only ────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D/§E, and Phase 6 of EVENTS_PLAN.md. What one run has spent of one
|
||||
// dimension, and the most it may.
|
||||
//
|
||||
// **The whole file exists for one statement.** `spend()` is the conditional
|
||||
// increment §E names as the answer to "two steps spending one cap":
|
||||
//
|
||||
// UPDATE … SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap
|
||||
//
|
||||
// A read-then-write would let two steps drawing on `uo.creatures` in the same
|
||||
// tick each see 28 of 30 and each spend 5. The cap in the WHERE means the second
|
||||
// one changes no rows, and `affectedRows === 0` *is* the refusal — no transaction,
|
||||
// no lock, and no second opinion about the arithmetic. Same shape as the outbox
|
||||
// claim, `runsDb.transition` and Phase 5's gate increment, and the same argument.
|
||||
//
|
||||
// **The SET list here has one assignment for the reason Phase 5's had three.**
|
||||
// MariaDB evaluates an UPDATE's SET assignments left to right, each seeing what
|
||||
// the ones before it assigned — which is how Phase 5's gate closed a firing early
|
||||
// — so nothing in this statement may read `consumed` after it has been written.
|
||||
// The guard is in the WHERE, where it reads the pre-update row, and it must stay
|
||||
// there.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Seed a run's budget rows.
|
||||
*
|
||||
* **INSERT IGNORE against `uq_evbud_dim`**, so a tick that overruns into the next
|
||||
* one cannot double-seed and cannot reset a cap a run has already spent against —
|
||||
* the idempotence `materialisePhase` and `gates.open` both have, for the same
|
||||
* reason.
|
||||
*
|
||||
* The caps are COPIED here rather than read live at dispatch. A run pins its
|
||||
* version and is reproducible in every other respect; a cap read live would be
|
||||
* the one input to a run's behaviour an admin could change underneath it while
|
||||
* nobody was watching, and the console's meter would answer "what is allowed now"
|
||||
* when the question afterwards is "what was this run allowed".
|
||||
*/
|
||||
async function seed(runId, dimensions) {
|
||||
const rows = Object.entries(dimensions || {})
|
||||
if (!rows.length) return 0
|
||||
const values = rows.map(() => '(?, ?, 0, ?, ?)').join(', ')
|
||||
const params = rows.flatMap(([dimension, d]) => [runId, dimension, d.cap, d.from || null])
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from)
|
||||
VALUES ${values}`,
|
||||
params,
|
||||
)
|
||||
return result.affectedRows || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend `amount` of one dimension, or refuse.
|
||||
*
|
||||
* Answers `true` when the row moved and `false` when it did not — and `false` has
|
||||
* exactly two causes, both of which mean the same thing to the caller: the spend
|
||||
* would breach the cap, or there is no row for this dimension at all. The second
|
||||
* is not a silent pass: a run whose version names an action that costs a
|
||||
* dimension always has that dimension seeded — **uncapped ones included, as a row
|
||||
* with a NULL cap** — so a missing row means the step is spending something its
|
||||
* own version never declared, and refusing that is the fail-closed direction.
|
||||
*
|
||||
* A zero or negative amount is not a spend and never touches the database. An
|
||||
* action whose `cost()` answers `0` for its params is telling core it consumes
|
||||
* nothing, and pricing that as a query would put one round trip per step behind a
|
||||
* fact the caller already has.
|
||||
*/
|
||||
async function spend(runId, dimension, amount) {
|
||||
if (!(amount > 0)) return true
|
||||
const result = await query(
|
||||
`UPDATE event_run_budget
|
||||
SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`,
|
||||
[amount, runId, dimension, amount],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Give `amount` back.
|
||||
*
|
||||
* Called on exactly one path: a step that spent several dimensions and was then
|
||||
* refused on a later one. The spends are separate statements — they must be, the
|
||||
* atomicity that matters is per dimension — so a step costing 5 creatures and 2
|
||||
* bosses can take the creatures and be refused the bosses, and a step that did
|
||||
* not run must not have spent anything. `GREATEST(consumed - ?, 0)` because the
|
||||
* floor is worth more than a refund that is exactly right: a negative `consumed`
|
||||
* would make the cap arithmetic lie in the permissive direction forever after.
|
||||
*/
|
||||
async function refund(runId, dimension, amount) {
|
||||
if (!(amount > 0)) return
|
||||
await query(
|
||||
`UPDATE event_run_budget
|
||||
SET consumed = GREATEST(consumed - ?, 0)
|
||||
WHERE run_id = ? AND dimension = ?`,
|
||||
[amount, runId, dimension],
|
||||
)
|
||||
}
|
||||
|
||||
/** Every dimension of one run, for the console's meter. */
|
||||
async function forRun(runId) {
|
||||
return query(
|
||||
`SELECT dimension, consumed, cap, effective_from
|
||||
FROM event_run_budget WHERE run_id = ? ORDER BY dimension`,
|
||||
[runId],
|
||||
)
|
||||
}
|
||||
|
||||
/** The dimensions of several runs at once, keyed by run id — the run LIST's read. */
|
||||
async function forRuns(runIds) {
|
||||
const ids = [...new Set(runIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT run_id, dimension, consumed, cap, effective_from
|
||||
FROM event_run_budget
|
||||
WHERE run_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY run_id, dimension`,
|
||||
ids,
|
||||
)
|
||||
const out = new Map()
|
||||
for (const r of rows) {
|
||||
if (!out.has(r.run_id)) out.set(r.run_id, [])
|
||||
out.get(r.run_id).push({
|
||||
dimension: r.dimension,
|
||||
consumed: r.consumed,
|
||||
cap: r.cap,
|
||||
effective_from: r.effective_from,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { seed, spend, refund, forRun, forRuns }
|
||||
@@ -40,6 +40,13 @@ const KINDS = [
|
||||
'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
|
||||
]
|
||||
|
||||
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
|
||||
|
||||
@@ -25,6 +25,9 @@ const gatesDb = require('./eventPhaseGates.db')
|
||||
const gates = require('../../events/gates')
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const settingsDb = require('./eventActionSettings.db')
|
||||
const budgetDb = require('./eventRunBudget.db')
|
||||
const authorize = require('../../events/authorize')
|
||||
|
||||
const MAX_SCOPE = 190
|
||||
|
||||
@@ -85,6 +88,22 @@ async function create(
|
||||
return { ok: false, status: 409, errors: ['the published version has no phases'] }
|
||||
}
|
||||
|
||||
// §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03):
|
||||
// *"a scheduled definition that has never been verified is the case worth
|
||||
// refusing to start"*. An admin pressing start is watching, and that human IS
|
||||
// the review the gate exists to require — so the gate falls on the path where
|
||||
// nobody is. A version is immutable, so a dry run that passed against it stays
|
||||
// true, which is what makes the pass a property of the version rather than
|
||||
// something re-earned every occurrence.
|
||||
if (source === 'schedule' && !version.verified_at) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: 'unverified',
|
||||
errors: ['this version has not been verified, so it will not start unattended'],
|
||||
}
|
||||
}
|
||||
|
||||
const scopeValue = String(scope || '').slice(0, MAX_SCOPE)
|
||||
const when = scheduledFor ? new Date(scheduledFor) : new Date()
|
||||
if (Number.isNaN(when.getTime())) {
|
||||
@@ -130,6 +149,32 @@ async function create(
|
||||
},
|
||||
})
|
||||
|
||||
// The run's budget, seeded from EVERY phase's steps rather than from the first
|
||||
// one's (Phase 6). The version is pinned and immutable, so all of its steps are
|
||||
// knowable now — and a budget that grew as phases were entered would let a
|
||||
// phase-1 step spend a cap that a phase-3 step was going to need, which is the
|
||||
// opposite of a per-run bound. The caps are copied here, so an admin moving a
|
||||
// switch tomorrow does not change what a run already in flight is allowed.
|
||||
const allSteps = version.spec.phases.flatMap((p) =>
|
||||
(p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })),
|
||||
)
|
||||
const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId))
|
||||
const budget = authorize.effectiveCaps(allSteps, settingsByAction)
|
||||
if (Object.keys(budget).length) {
|
||||
await budgetDb.seed(runId, budget)
|
||||
await logDb.write({
|
||||
runId,
|
||||
kind: 'run.budget',
|
||||
detail: {
|
||||
dimensions: Object.entries(budget).map(([dimension, d]) => ({
|
||||
dimension,
|
||||
cap: d.cap,
|
||||
from: d.from,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The first phase's steps, materialised at creation rather than at start.
|
||||
// Phase 2 materialises each LATER phase as the run enters it; doing the first
|
||||
// one here is what makes a Phase 1 run row inspectable — an operator can see
|
||||
@@ -159,13 +204,29 @@ async function create(
|
||||
async function detail(runId) {
|
||||
const run = await db.getById(runId)
|
||||
if (!run) return null
|
||||
const [steps, counts, gateRows] = await Promise.all([
|
||||
const [steps, counts, gateRows, budget] = await Promise.all([
|
||||
stepsDb.listForRun(runId),
|
||||
stepsDb.statusCounts(runId),
|
||||
gatesDb.listForRun(runId),
|
||||
budgetDb.forRun(runId),
|
||||
])
|
||||
const now = new Date()
|
||||
return { run, steps, counts, gates: gateRows.map((g) => gates.describe(g, now)) }
|
||||
return {
|
||||
run,
|
||||
steps,
|
||||
counts,
|
||||
gates: gateRows.map((g) => gates.describe(g, now)),
|
||||
// The meter, as rows rather than as a sentence: a cap is two numbers and a
|
||||
// name, and unlike a gate it needs no grammar rendered to be read. `cap:
|
||||
// null` is uncapped and the client says so — a dimension the run counts but
|
||||
// nothing bounds.
|
||||
budget: budget.map((b) => ({
|
||||
dimension: b.dimension,
|
||||
consumed: b.consumed,
|
||||
cap: b.cap,
|
||||
from: b.effective_from,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { create, detail, renderConcurrencyKey }
|
||||
|
||||
@@ -51,4 +51,24 @@ const insert = async (definitionId, version, spec, userId) => {
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
module.exports = { listForDefinition, getById, nextVersion, insert }
|
||||
/**
|
||||
* Record that a dry run passed against this version (Phase 6).
|
||||
*
|
||||
* A version is immutable in every respect that describes the EVENT — its spec,
|
||||
* its number, who published it. These two columns describe something that
|
||||
* happened to it afterwards, which is why they can be written at all: a pass is
|
||||
* a fact about a review, not a change to the plan reviewed.
|
||||
*
|
||||
* Deliberately not idempotent-checked: verifying twice stamps the second one, and
|
||||
* the later reviewer is the more useful answer to "who last looked at this
|
||||
* before it ran unattended".
|
||||
*/
|
||||
const markVerified = async (id, userId, at = new Date()) => {
|
||||
const result = await query(
|
||||
'UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?',
|
||||
[at, userId, id],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
module.exports = { listForDefinition, getById, nextVersion, insert, markVerified }
|
||||
|
||||
Reference in New Issue
Block a user