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

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