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 }
|
||||
|
||||
Reference in New Issue
Block a user