fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179)
Some checks failed
PR Checks / server-tests (pull_request) Failing after 16s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 37s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 17:16:45 -05:00
parent 36eae7ffa8
commit 654a24585d
13 changed files with 515 additions and 37 deletions

View File

@@ -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 }

View File

@@ -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