fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179)
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:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user