From 654a24585d5d7bad783bcb2f2e719e761e8a84fb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 17:16:45 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(rust):=20protocol=2013=20=E2=80=94=20a?= =?UTF-8?q?=20configuration=20save=20that=20settles=20after=20its=20reload?= =?UTF-8?q?=20(F9,=20D179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin now answers a save once the files are written, with pending and a writeId, and reports the reload later as a config.outcome event. The save is recorded as reloading with a settle_by of two plugin ceilings plus slack on the database's clock; ingest settles the row by (server, writeId), only while it is still reloading, so a replay moves nothing and a late outcome still lands. A row past settle_by reads as lost. GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page polls it every two seconds, holds the Save button while it waits, and says whether a rolled-back plugin came back on its old file. config.outcome is a staff kind: it carries the server's log tail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/api.js | 6 ++ client/src/routes/admin/ModConfig.jsx | 105 ++++++++++++++++++-- server/catalogue.js | 4 + server/db/schema.sql | 24 ++++- server/ingest.js | 14 +++ server/model/config/config.db.js | 88 ++++++++++++++--- server/model/config/config.model.js | 50 ++++++++++ server/router/admin/config.controller.js | 72 ++++++++++++-- server/router/admin/config.router.js | 18 +++- server/sidecarClient.js | 2 +- server/test/catalogue.test.js | 7 +- server/test/config.test.js | 120 +++++++++++++++++++++++ swagger-fragment.json | 42 +++++++- 13 files changed, 515 insertions(+), 37 deletions(-) diff --git a/client/src/api.js b/client/src/api.js index c31c3b6..252d1fe 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -220,6 +220,12 @@ export const adminConfig = { writes: (serverId, limit = null) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`), + + // One write, polled while its reload settles (protocol 13). A save that + // reloads a plugin answers before the reload has finished — behind a cold + // compile that can be many seconds — and this is where the outcome lands. + write: (serverId, id) => + req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes/${encodeURIComponent(id)}`), } // ── the admin.users.detail extension slot ───────────────────────────────── diff --git a/client/src/routes/admin/ModConfig.jsx b/client/src/routes/admin/ModConfig.jsx index a30af7f..1dd9fa2 100644 --- a/client/src/routes/admin/ModConfig.jsx +++ b/client/src/routes/admin/ModConfig.jsx @@ -138,20 +138,59 @@ function Field({ field, value, onChange, revealed, onReveal }) { ) } +/** How often a save that is still reloading asks how it went. */ +const POLL_MS = 2000 + +/** + * A settled write row as the report panel reads it. + * + * The row is this module's audit record, not the plugin's frame, so it carries + * no per-file `rewritten` — the file is re-read after either way, which is what + * the panel's note about rewriting was for. + */ +function reportFromWrite(write) { + return { + pending: write.outcome === 'reloading', + lost: write.outcome === 'lost', + reload: write.reloadTarget, + reloaded: Boolean(write.reloaded), + rolledBack: write.outcome === 'rolled-back', + restored: write.restored, + reason: write.detail, + log: write.log, + files: [], + } +} + +/** The sentence at the top of the panel. */ +function headline(report) { + if (report.pending) { + return `Saved. Reloading ${report.reload || 'the plugin'}… a first compile after a quiet spell can take a while.` + } + + if (report.lost) { + return 'The server never said how the reload went — the bridge may have been reloaded, or the link dropped. Re-read the file to see what is on disk.' + } + + if (report.rolledBack) { + return report.restored === false + ? 'The plugin did not come back, so the old file was put back — and it did not come back on the old file either. It is down: check the server console.' + : 'The plugin did not come back, so the old file was put back automatically.' + } + + return report.reloaded ? 'Saved, and the plugin reloaded.' : `Saved. ${report.reason || 'Nothing was reloaded.'}` +} + /** What the game said happened. The rollback case is the one worth reading. */ function Report({ report }) { if (!report) return null - const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)' + const tone = report.rolledBack || report.lost ? '#e05a5a' : 'var(--ink)' return (

- {report.rolledBack - ? 'The plugin did not come back, so the old file was put back automatically.' - : report.reloaded - ? 'Saved, and the plugin reloaded.' - : `Saved. ${report.reason || 'Nothing was reloaded.'}`} + {headline(report)}

{report.rolledBack && report.reason && (

@@ -196,6 +235,8 @@ export default function ModConfig() { const [error, setError] = useState('') const [report, setReport] = useState(null) const [fileNonce, setFileNonce] = useState(0) + // The write a save left reloading, polled until it settles (protocol 13, D179). + const [watching, setWatching] = useState(null) const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), []) @@ -243,8 +284,48 @@ export default function ModConfig() { useEffect(() => { setPath('') setReport(null) + setWatching(null) }, [serverId]) + // A save that reloads a plugin comes back before the reload has finished. + // Ask how it went every couple of seconds until the row leaves `reloading` — + // the server calls it `lost` once twice the plugin's ceiling has passed, so + // this always ends — then re-read the file, because a reload rewrites it and a + // rollback puts the old one back. + useEffect(() => { + if (!watching) return undefined + + let stopped = false + let handle = null + + const poll = async () => { + try { + const { write } = await api.adminConfig.write(watching.serverId, watching.id) + if (stopped) return + + setReport(reportFromWrite(write)) + + if (write.outcome !== 'reloading') { + setWatching(null) + setFileNonce((n) => n + 1) + return + } + } catch { + // One failed poll is a blip, not an answer; the next one asks again. + if (stopped) return + } + + handle = setTimeout(poll, POLL_MS) + } + + handle = setTimeout(poll, POLL_MS) + + return () => { + stopped = true + clearTimeout(handle) + } + }, [watching]) + if (serverError) return if (!servers) return @@ -277,7 +358,13 @@ export default function ModConfig() { const answer = await api.adminConfig.save(serverId, body) - setReport(answer.report || null) + if (answer.pending && answer.write && answer.write.id) { + setReport({ ...(answer.report || {}), pending: true, reload: reload || null, files: [] }) + setWatching({ serverId, id: answer.write.id }) + } else { + setReport(answer.report || null) + } + if (!answer.changed) setError('Nothing changed, so nothing was written.') // Re-read either way: a successful reload usually rewrites the file with @@ -483,10 +570,10 @@ export default function ModConfig() {

diff --git a/server/catalogue.js b/server/catalogue.js index f39ce84..2b61949 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -80,6 +80,10 @@ const STAFF_KINDS = Object.freeze([ // changed it by hand — a question about a person's standing and about an // operator's own console, neither of which is a public page's business. 'perm.drift', + // Protocol 13. How a configuration save's reload ended — and, when it failed, + // the tail of the game server's own log, which is an operator's console and + // can hold anything a plugin chose to print. + 'config.outcome', // Protocol 6. Clan membership, which the org lead made members-only (D49): // who joined which clan, and who threw whom out, is the clan's business. It // reaches a clan's own members through core's Team feed, where core resolves diff --git a/server/db/schema.sql b/server/db/schema.sql index 2886ebd..c4e1fac 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -651,7 +651,7 @@ CREATE TABLE IF NOT EXISTS rust_config_writes ( -- whole document. tier VARCHAR(16) NOT NULL DEFAULT 'form', user_id INT NULL, - -- `applied` | `rolled-back` | `refused` | `unreachable` + -- `applied` | `rolled-back` | `refused` | `unreachable` | `reloading` (protocol 13) outcome VARCHAR(24) NOT NULL, reloaded TINYINT(1) NOT NULL DEFAULT 0, changes LONGTEXT NULL, @@ -669,6 +669,28 @@ CREATE TABLE IF NOT EXISTS rust_config_writes ( KEY idx_rust_config_writes_server (server_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Protocol 13 (PLAN_FIXES.md F9, D177–D179). A save that reloads a plugin no +-- longer waits for the reload: it is recorded as `reloading` the moment the files +-- land, and ingest settles it from the plugin's `config.outcome`. +-- +-- • `write_id` is the plugin's own name for the write — the frame carries it, +-- and the sidecar's request ids restart with the sidecar, so they cannot. +-- • `settle_by` is when a write that never heard back is LOST rather than slow: +-- two of the plugin's ceilings (the edit's reload and a restore's) plus +-- slack. Reads report `lost` past it; the row itself stays `reloading`, so an +-- outcome that arrives late — a link that was down — still settles it. +-- • `log_tail` is the server's log at the moment a reload failed. It used to go +-- straight back to the browser that asked; nothing is waiting now, so the +-- page reads it from here. Operator console output: admin-only like the rest. +-- • `restored` is whether the plugin came back on its old file after a +-- rollback. NULL when there was no rollback. +ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS write_id VARCHAR(64) NULL; +ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS settle_by DATETIME NULL; +ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS settled_at DATETIME NULL; +ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS log_tail TEXT NULL; +ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS restored TINYINT(1) NULL; +CREATE INDEX IF NOT EXISTS idx_rust_config_writes_write ON rust_config_writes (server_id, write_id); + -- ── Who may see who is online (the presence fix, 2026-09-22) ────────────── -- diff --git a/server/ingest.js b/server/ingest.js index 41b7de3..c06363e 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -34,6 +34,8 @@ const core = require('./core') const clans = require('./model/clans/clans.model') +const configDb = require('./model/config/config.db') +const configModel = require('./model/config/config.model') const db = require('./model/events/events.db') const engagement = require('./engagement/emit') const links = require('./model/links/links.model') @@ -199,6 +201,18 @@ async function apply(serverId, item, server = null) { await permissionsDb.markDirty(serverId) break + // ── Protocol 13: how a configuration save's reload ended (F9, D179) ───── + // + // The save was answered before the reload finished and recorded as + // `reloading`; this is the rest of it. Only a row still `reloading` moves, + // so a replayed frame changes nothing, and one that arrives after the page + // gave up on it (`lost`) still lands — the audit row ends up true either way. + case 'config.outcome': { + const outcome = configModel.outcomeOf(frame) + if (outcome) await configDb.settleWrite(serverId, outcome) + break + } + // ── Protocol 6: first-party clans ────────────────────────────────────── // // Each one is told to core as it happens (`ctx.teams.publish`) and written diff --git a/server/model/config/config.db.js b/server/model/config/config.db.js index 2126f4e..fae6813 100644 --- a/server/model/config/config.db.js +++ b/server/model/config/config.db.js @@ -18,11 +18,11 @@ const core = require('../../core') * silence. */ async function recordWrite(row) { - await core.query( + const result = await core.query( `INSERT INTO rust_config_writes (server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded, - changes, version_before, version_after, detail) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + changes, version_before, version_after, detail, write_id, settle_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`, [ row.serverId, row.path, @@ -36,8 +36,55 @@ async function recordWrite(row) { row.versionBefore || null, row.versionAfter || null, row.detail ? String(row.detail).slice(0, 500) : null, + row.writeId || null, + // Seconds from now, on the database's clock — the one `lost` is read against. + // NULL for a write nothing is waiting on, and DATE_ADD of NULL is NULL. + row.settleSeconds != null ? Number(row.settleSeconds) : null, ], ) + + return result && result.insertId != null ? Number(result.insertId) : null +} + +/** + * Settles a `reloading` write from the plugin's `config.outcome` (protocol 13). + * + * Found by the plugin's `writeId`, scoped to the server that sent it — a write + * id is only unique within one plugin. Only a row still `reloading` moves, so a + * replayed frame settles nothing twice. A row whose `settle_by` has passed is + * still `reloading` in the table (it reads as `lost`), so a late outcome still + * lands. Returns whether a row moved. + */ +async function settleWrite(serverId, outcome) { + const result = await core.query( + `UPDATE rust_config_writes + SET outcome = ?, reloaded = ?, restored = ?, version_after = ?, detail = ?, + log_tail = ?, settled_at = NOW() + WHERE server_id = ? AND write_id = ? AND outcome = 'reloading'`, + [ + outcome.outcome, + outcome.reloaded ? 1 : 0, + outcome.restored == null ? null : outcome.restored ? 1 : 0, + outcome.versionAfter || null, + outcome.detail ? String(outcome.detail).slice(0, 500) : null, + outcome.log || null, + serverId, + outcome.writeId, + ], + ) + + return Number((result && result.affectedRows) || 0) > 0 +} + +/** One write, by its row id, for the page that is waiting on it. */ +async function getWrite(serverId, id) { + const rows = await core.query( + `${SELECT_WRITE} + WHERE server_id = ? AND id = ?`, + [serverId, id], + ) + + return rows[0] ? shapeWrite(rows[0]) : null } /** @@ -49,21 +96,40 @@ async function recordWrite(row) { */ async function recentWrites(serverId, limit = 50) { const rows = await core.query( - `SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier, - user_id AS userId, outcome, reloaded, changes, - version_before AS versionBefore, version_after AS versionAfter, detail, created_at AS createdAt - FROM rust_config_writes + `${SELECT_WRITE} WHERE server_id = ? ORDER BY id DESC LIMIT ?`, [serverId, Math.max(1, Math.min(Number(limit) || 50, 200))], ) - return rows.map((row) => ({ - ...row, + return rows.map(shapeWrite) +} + +// `lost` is decided by the database's clock, in the same statement that reads +// the row, so it cannot disagree with `settle_by`, which that clock also wrote. +const SELECT_WRITE = `SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier, + user_id AS userId, outcome, reloaded, restored, changes, + version_before AS versionBefore, version_after AS versionAfter, detail, + log_tail AS log, write_id AS writeId, settled_at AS settledAt, created_at AS createdAt, + (outcome = 'reloading' AND settle_by IS NOT NULL AND settle_by < NOW()) AS lost + FROM rust_config_writes` + +/** + * A row as the page reads it. A write still `reloading` past its `settle_by` + * reads as `lost`: the plugin was reloaded or the link dropped before it could + * say, and the files are whatever a re-read shows. + */ +function shapeWrite(row) { + const { lost, ...rest } = row + + return { + ...rest, + outcome: Number(lost) ? 'lost' : row.outcome, reloaded: Boolean(row.reloaded), + restored: row.restored == null ? null : Boolean(row.restored), changes: parseChanges(row.changes), - })) + } } function parseChanges(raw) { @@ -76,4 +142,4 @@ function parseChanges(raw) { } } -module.exports = { recordWrite, recentWrites, parseChanges } +module.exports = { recordWrite, settleWrite, getWrite, recentWrites, parseChanges, shapeWrite } diff --git a/server/model/config/config.model.js b/server/model/config/config.model.js index 22c4e9f..4ac6cdb 100644 --- a/server/model/config/config.model.js +++ b/server/model/config/config.model.js @@ -198,6 +198,12 @@ function summariseReport(report) { ok: report.ok !== false, reloaded: Boolean(report.reloaded), rolledBack: Boolean(report.rolledBack), + // Protocol 13: the files are written and the reload has not finished. The + // outcome follows as a `config.outcome` frame naming `writeId`. + pending: Boolean(report.pending), + writeId: report.writeId ? String(report.writeId) : null, + ceilingMs: Number(report.ceilingMs) > 0 ? Number(report.ceilingMs) : null, + restored: typeof report.restored === 'boolean' ? report.restored : null, reason: report.reason ? String(report.reason) : null, // The plugin only reads this on the failure path, and it is the difference // between "your change was undone" and "your change was undone BECAUSE line @@ -215,6 +221,48 @@ function summariseReport(report) { } } +/** + * How long a pending write may go unanswered before it is lost, in seconds. + * + * The plugin waits at most one ceiling for the edit's reload and one more for a + * restore's; ingest reads the feed every few seconds on top. Past both, with a + * margin, nothing is coming — the plugin was reloaded or the link dropped — and + * the page says so instead of spinning. A plugin that sent no ceiling gets the + * one protocol 13 shipped with. + */ +const SETTLE_SLACK_SECONDS = 20 +const DEFAULT_CEILING_MS = 30 * 1000 + +function settleSeconds(ceilingMs) { + const ceiling = Number(ceilingMs) > 0 ? Number(ceilingMs) : DEFAULT_CEILING_MS + return Math.ceil((2 * ceiling) / 1000) + SETTLE_SLACK_SECONDS +} + +/** + * A `config.outcome` frame as the row it settles, or null when it names no write. + * + * `applied` covers every case where the edit is on disk — reloaded, or standing + * because the framework never reloaded it (the reason says which). A rollback is + * `rolled-back` whether or not the plugin came back on the old file; `restored` + * says which, and it is the difference between "try again" and "go and look". + */ +function outcomeOf(frame) { + if (!frame || typeof frame !== 'object' || !frame.writeId) return null + + const report = summariseReport(frame) + const first = report.files[0] + + return { + writeId: String(frame.writeId), + outcome: report.rolledBack ? 'rolled-back' : 'applied', + reloaded: report.reloaded, + restored: report.rolledBack ? report.restored : null, + versionAfter: first ? first.version : null, + detail: report.reason, + log: report.log, + } +} + module.exports = { LOCKED_KEYS, LOCKED_REASON, @@ -226,4 +274,6 @@ module.exports = { lockedKeysFor, lockedChanges, summariseReport, + outcomeOf, + settleSeconds, } diff --git a/server/router/admin/config.controller.js b/server/router/admin/config.controller.js index 1ed08f4..a3c6608 100644 --- a/server/router/admin/config.controller.js +++ b/server/router/admin/config.controller.js @@ -1,9 +1,10 @@ // ── Admin · Rust · Mod configuration ────────────────────────────────────── // -// R18. Four routes: list the tree, read a file, write a file, read what has -// been written lately. Every one of them is a live round trip to a game host — -// nothing here is cached, because a cached config is an edit somebody made over -// SSH that this website then silently overwrote. +// R18. Five routes: list the tree, read a file, write a file, read what has +// been written lately, and read one write while its reload settles. The first +// three are live round trips to a game host — nothing here is cached, because a +// cached config is an edit somebody made over SSH that this website then +// silently overwrote. The last two read this module's own audit table. // // ── The write is three steps and the order is the whole design ──────────── // @@ -17,8 +18,10 @@ // fails to come back from its reload. // 3. **Hand the whole file to the plugin**, which version-checks it again, // backs the old one up, writes, reloads, and rolls the write back if the -// plugin does not announce itself. That last part is the feature; this file -// reports it. +// plugin fails to load. That last part is the feature; this file reports it. +// Since protocol 13 the plugin answers as soon as the files are written and +// reports the reload later as a `config.outcome` frame (F9), so a save that +// reloads is recorded `reloading` and settled by ingest (D179). // // ── What the outcome means ──────────────────────────────────────────────── // @@ -238,6 +241,29 @@ async function writeFile(req, res) { const report = model.summariseReport(reply.data) const after = report && report.files[0] ? report.files[0].version : null + // Protocol 13 (F9, D179): the files are on disk and the reload is still + // running — behind a cold compile it can take longer than any request should. + // The row is `reloading` until ingest settles it from the plugin's + // `config.outcome`; the page polls it by the id returned here. + if (report && report.pending) { + const id = await record(req, { + serverId, + path, + self, + reload, + tier, + outcome: 'reloading', + reloaded: false, + changes, + versionBefore: onDisk.version, + versionAfter: after, + writeId: report.writeId, + settleSeconds: model.settleSeconds(report.ceilingMs), + }) + + return res.json({ changed: true, pending: true, write: { id, writeId: report.writeId }, report }) + } + await record(req, { serverId, path, @@ -268,10 +294,34 @@ async function history(req, res) { } } -/** One audit row, plus the activity entry core owns. Never lets a logging failure fail a save. */ -async function record(req, row) { +/** + * One write, for the page waiting on a reload (D179). + * + * `reloading` until ingest settles it, then `applied` or `rolled-back` with the + * reason and the server's log; `lost` once it has gone unanswered past twice the + * plugin's ceiling, which means re-read the file rather than keep waiting. + */ +async function writeStatus(req, res) { try { - await db.recordWrite({ + const write = await db.getWrite(req.params.serverId, Number(req.params.writeId)) + if (!write) return res.status(404).json({ message: 'No such configuration write' }) + + return res.json({ write }) + } catch (err) { + log.error('failed to read a configuration write', { error: err.message }) + return res.status(500).json({ message: 'Failed to read that configuration write' }) + } +} + +/** + * One audit row, plus the activity entry core owns. Never lets a logging failure + * fail a save. Returns the row's id, or null when it could not be written. + */ +async function record(req, row) { + let id = null + + try { + id = await db.recordWrite({ ...row, plugin: model.isBridgeConfig(row.path, row.self) ? row.self : pluginOf(row.path), reloadTarget: row.reload, @@ -293,6 +343,8 @@ async function record(req, row) { } catch (err) { log.error('failed to record a configuration write', { path: row.path, error: err.message }) } + + return id } function pluginOf(path) { @@ -328,4 +380,4 @@ function refusalStatus(frame) { return 502 } -module.exports = { listFiles, readFile, writeFile, history, refusalMessage, refusalStatus } +module.exports = { listFiles, readFile, writeFile, writeStatus, history, refusalMessage, refusalStatus } diff --git a/server/router/admin/config.router.js b/server/router/admin/config.router.js index 7336807..f635af2 100644 --- a/server/router/admin/config.router.js +++ b/server/router/admin/config.router.js @@ -67,8 +67,8 @@ configRouter.post( '/:serverId/file', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Save a configuration file and reload its plugin' - // #swagger.description = 'Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin does not come back — which is answered 200 with `report.rolledBack`, because a rollback is a round trip that worked and an edit that did not.' - /* #swagger.responses[200] = { description: 'What happened: applied, or rolled back with the reason' } */ + // #swagger.description = 'Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin fails to load. With a plugin to reload, the answer comes as soon as the file is written — `pending: true` and `write.id` — and the outcome is read from `GET …/writes/{writeId}` once the reload settles. Without one, the answer is final.' + /* #swagger.responses[200] = { description: 'Written and reloading (`pending`, with the write’s id), or the final outcome when nothing was reloaded' } */ /* #swagger.responses[400] = { description: 'Invalid body, an edit the form may not make, or a locked key' } */ /* #swagger.responses[409] = { description: 'The file changed on the host since it was read' } */ /* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */ @@ -105,4 +105,18 @@ configRouter.get( config.history, ) +configRouter.get( + '/:serverId/writes/:writeId', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'One configuration write, while its reload settles' + // #swagger.description = 'A save that reloads a plugin answers as soon as the file is written, with `pending: true` and this write’s id; the reload can take longer than a request should, behind a cold compile. Poll this until `outcome` leaves `reloading`: `applied`, or `rolled-back` with the reason, the server’s log, and `restored` (whether the plugin came back on its old file). `lost` means the plugin never reported — it was reloaded, or the link dropped — so re-read the file.' + /* #swagger.responses[200] = { description: 'The write and how far it has got' } */ + /* #swagger.responses[404] = { description: 'No such write on that server' } */ + requireRole('admin'), + param('serverId').matches(SERVER_ID), + param('writeId').isInt({ min: 1 }), + validate, + config.writeStatus, +) + module.exports = configRouter diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 91075ef..48ca667 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -79,7 +79,7 @@ const TIMEOUT_MS = 12000 * deployment into a `409` naming both numbers instead of a parse failure three * layers further in. */ -const PROTOCOL_VERSION = 12 +const PROTOCOL_VERSION = 13 /** What a caller gets back. Shaped once so every call site reads the same. */ function reply(ok, status, data = null) { diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index 64b9e12..6099bda 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -95,7 +95,7 @@ test('every kind is classified exactly once', () => { assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length) }) -test('the classification covers exactly the kinds the protocol defines, through protocol 6', () => { +test('the classification covers exactly the kinds the protocol defines, through protocol 6, and protocol 13’s config.outcome', () => { // The spec lives in another repository, so the list is restated here rather // than parsed — and restating it is the point: adding a kind to the protocol // without deciding who may see it has to fail somewhere, and this is where. @@ -127,6 +127,9 @@ test('the classification covers exactly the kinds the protocol defines, through 'clan.member.added', 'clan.member.left', 'clan.member.kicked', + // Protocol 13 (§19). A configuration save's outcome carries the server's + // log tail, which is an operator's console: staff only. + 'config.outcome', ] assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort()) @@ -134,6 +137,8 @@ test('the classification covers exactly the kinds the protocol defines, through for (const kind of PROTOCOL_4.filter((k) => k.startsWith('clan.'))) { assert.equal(catalogue.isPublic(kind), false, `${kind} is members-only and must not be public`) } + + assert.equal(catalogue.isPublic('config.outcome'), false, 'a server’s log tail must not be public') }) test('every kind that names a player who was on is behind the presence setting', () => { diff --git a/server/test/config.test.js b/server/test/config.test.js index 84e49e0..8842af4 100644 --- a/server/test/config.test.js +++ b/server/test/config.test.js @@ -435,3 +435,123 @@ test('the bridge’s own file is recognised by the plugin’s name, not by a fil // locking everything or guessing. assert.deepEqual(model.lockedKeysFor('RunicGateway.json', null), []) }) + +// ── Protocol 13: the save that answers before its reload does (F9, D179) ── + +test('a save the plugin is still reloading is recorded as reloading, with the id the page polls', async () => { + const queries = withCore() + stubSidecar({ + write: { + ok: true, + status: 'ok', + data: { + kind: 'config.report', + ok: true, + reloaded: false, + rolledBack: false, + pending: true, + writeId: 'w-1', + reload: 'ZoneManager', + ceilingMs: 30000, + files: [{ path: 'ZoneManager.json', version: 'v-written' }], + }, + }, + }) + stubServer() + + const res = fakeRes() + await controller().writeFile( + { + params: { serverId: 'main' }, + body: { + path: 'ZoneManager.json', + version: 'v-ZoneManager.json', + reload: 'ZoneManager', + edits: [{ pointer: ['Enabled'], value: false }], + }, + }, + res, + ) + + assert.equal(res.statusCode, 200) + assert.equal(res.body.pending, true) + assert.deepEqual(res.body.write, { id: 1, writeId: 'w-1' }) + + const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes')) + assert.ok(audit.params.includes('reloading')) + assert.ok(audit.params.includes('w-1'), 'the plugin’s write id is what ingest settles the row by') + + // Two ceilings — the edit's reload and a restore's — plus slack, as seconds + // on the database's clock. + assert.equal(audit.params[audit.params.length - 1], 80) + assert.match(audit.sql, /DATE_ADD\(NOW\(\), INTERVAL \? SECOND\)/) +}) + +test('a config.outcome settles only the write it names, on the server that sent it, and only once', async () => { + const queries = withCore() + const ingest = require('../ingest') + + await ingest.apply('main', { + kind: 'config.outcome', + frame: { + kind: 'config.outcome', + type: 'event', + serverId: 'main', + writeId: 'w-1', + reload: 'Kits', + ok: false, + reloaded: false, + rolledBack: true, + restored: true, + reason: "'Kits' failed to load: Failed to initialize plugin 'Kits v4.4.9'", + log: 'Failed to initialize plugin ...', + files: [{ path: 'Kits.json', version: 'v-restored' }], + }, + }) + + const settle = queries.find((q) => q.sql.startsWith('UPDATE rust_config_writes')) + assert.ok(settle, 'the outcome must settle the row') + assert.match(settle.sql, /WHERE server_id = \? AND write_id = \? AND outcome = 'reloading'/) + assert.deepEqual(settle.params.slice(0, 4), ['rolled-back', 0, 1, 'v-restored']) + assert.deepEqual(settle.params.slice(-2), ['main', 'w-1']) +}) + +test('an outcome is applied when the edit stands, and rolled-back says whether the plugin came back', () => { + const model = require('../model/config/config.model') + + const applied = model.outcomeOf({ writeId: 'w', reloaded: true, rolledBack: false, files: [{ path: 'a.json', version: 'v2' }] }) + assert.equal(applied.outcome, 'applied') + assert.equal(applied.restored, null) + assert.equal(applied.versionAfter, 'v2') + + // The framework never reloaded it: the file stands, so it is applied, and the + // reason is what says it is not in effect yet. + const standing = model.outcomeOf({ writeId: 'w', reloaded: false, rolledBack: false, reason: 'the file stands', files: [] }) + assert.equal(standing.outcome, 'applied') + assert.equal(standing.detail, 'the file stands') + + const down = model.outcomeOf({ writeId: 'w', rolledBack: true, restored: false, files: [] }) + assert.equal(down.outcome, 'rolled-back') + assert.equal(down.restored, false) + + assert.equal(model.outcomeOf({ rolledBack: true }), null, 'an outcome naming no write settles nothing') + assert.equal(model.settleSeconds(null), 80, 'a plugin that sent no ceiling gets the one protocol 13 shipped with') +}) + +test('a write still reloading past its deadline reads as lost, and a settled one as itself', () => { + const db = require('../model/config/config.db') + + const base = { id: 7, outcome: 'reloading', reloaded: 0, restored: null, changes: null } + assert.equal(db.shapeWrite({ ...base, lost: 1 }).outcome, 'lost') + assert.equal(db.shapeWrite({ ...base, lost: 0 }).outcome, 'reloading') + assert.equal(db.shapeWrite({ ...base, outcome: 'rolled-back', restored: 1, lost: 0 }).restored, true) + assert.equal('lost' in db.shapeWrite({ ...base, lost: 0 }), false, 'the flag is folded into outcome, not leaked') +}) + +test('the page’s poll answers 404 for a write that is not on that server', async () => { + withCore() + const res = fakeRes() + await controller().writeStatus({ params: { serverId: 'main', writeId: '9' } }, res) + + assert.equal(res.statusCode, 404) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json index dea0625..b984565 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -41,7 +41,7 @@ "Admin · Rust" ], "summary": "Save a configuration file and reload its plugin", - "description": "Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin does not come back — which is answered 200 with `report.rolledBack`, because a rollback is a round trip that worked and an edit that did not.", + "description": "Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin fails to load. With a plugin to reload, the answer comes as soon as the file is written — `pending: true` and `write.id` — and the outcome is read from `GET …/writes/{writeId}` once the reload settles. Without one, the answer is final.", "parameters": [ { "name": "serverId", @@ -54,7 +54,7 @@ ], "responses": { "200": { - "description": "What happened: applied, or rolled back with the reason" + "description": "Written and reloading (`pending`, with the write’s id), or the final outcome when nothing was reloaded" }, "400": { "description": "Invalid body, an edit the form may not make, or a locked key" @@ -158,6 +158,44 @@ } } }, + "/api/v1/admin/rust/config/{serverId}/writes/{writeId}": { + "get": { + "tags": [ + "Admin · Rust" + ], + "summary": "One configuration write, while its reload settles", + "description": "A save that reloads a plugin answers as soon as the file is written, with `pending: true` and this write’s id; the reload can take longer than a request should, behind a cold compile. Poll this until `outcome` leaves `reloading`: `applied`, or `rolled-back` with the reason, the server’s log, and `restored` (whether the plugin came back on its old file). `lost` means the plugin never reported — it was reloaded, or the link dropped — so re-read the file.", + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "writeId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The write and how far it has got" + }, + "404": { + "description": "No such write on that server" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/v1/admin/rust/permissions": { "get": { "tags": [ -- 2.49.1 From fee0b294a9e7305017c471704c09f2c6b3ead9f3 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 17:34:09 -0500 Subject: [PATCH 2/2] fix(rust): freeze the write-poll route in routes.manifest.json The new GET /admin/rust/config/:serverId/writes/:writeId was documented in swagger-fragment.json but not in the committed manifest, so the frozen- manifest job and frozenManifest.test.js both failed. Regenerated against core at the pinned ref (efa9db7), exactly as the job does: one route added, nothing of core's moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- routes.manifest.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/routes.manifest.json b/routes.manifest.json index 1575d14..15ca0aa 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -51,6 +51,11 @@ "path": "/api/v1/admin/rust/config/:serverId/writes", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/rust/config/:serverId/writes/:writeId", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/admin/rust/permissions", -- 2.49.1