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