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

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