Test activation conflicts and draft-preserving application reload

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
This commit is contained in:
tegwick 2026-09-26 23:30:15 +02:00
parent e34ec3fafe
commit f273a190f0
13 changed files with 1385 additions and 0 deletions

View file

@ -48,3 +48,30 @@ can already have side effects before that check. No backend writes, authenticati
API migrations, atomic/concurrent map updates, accessibility audit or safe reload of
unsaved work is modeled. The startup outage reflects this fixture's chosen dependency,
not an assertion that every independently released frontend must fail that way.
## Activation conflicts and safe update reload
[Review](../../reviews/ActivationConflictsAndReload.md) and
[receipt](conflicts-reload-results.json): three deterministic stale-writer HTTP cases
and nine reload scenarios in each browser, 21/21 expected observations.
```bash
PLAYWRIGHT_BROWSERS_PATH=/tmp/fep-playwright-browsers FEP_CHROMIUM_PATH=/path/to/chrome node conflicts-reload.mjs
```
Uses the same pinned dependencies/browsers as the original release experiment.
Default output `/tmp/fep-conflicts-reload.json`; `FEP_RESULT_PATH` overrides it.
Incomplete browser progress goes to `/tmp/fep-conflicts-reload-checkpoint.json`.
The earlier host, runner and receipts remain unchanged. The new reload-host.js is
an authored policy using sessionStorage, explicit user choices and schema checks.
C01 and R01 deliberately reproduce data loss from unsafe choices. C02 refetches and
reapplies a disjoint intent; C03 rejects a conflicting stale rollback. R02–R09 exercise
update deferral, preservation, storage refusal, incompatible schema, explicit discard,
unknown-outcome gating, edits during preflight and activation after preflight.
Activation comparison/replacement is atomic only within this single-process model;
its revision header and 409 response are not a full HTTP precondition implementation.
Storage refusal is injected. Preservation assumes retention is authorized and protects
only the tested same-tab reload. Native reload/tab close, crashes, real backend
outcome recovery, multiple tabs and automatic schema migration remain out of scope.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,63 @@
import {chromium,firefox} from 'playwright';
import {createServer} from 'node:http';
import {once} from 'node:events';
import {readFile,writeFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {createHash} from 'node:crypto';
import assert from 'node:assert/strict';
const dir=fileURLToPath(new URL('.',import.meta.url));
const names=['reload-host.js','reports-r1.js','reports-r2.js'];
const assets=new Map(await Promise.all(names.map(async n=>['/'+n,await readFile(dir+n)])));
let state,trace,held,holdNext;
function reset(){state={revision:1,reports:'/reports-r1.js',account:'A1',draftSchema:1};trace=[];held=null;holdNext=false;}
reset();
const copy=()=>({...state});
const json=(res,code,value)=>{res.writeHead(code,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(value));};
const server=createServer(async(req,res)=>{
const path=new URL(req.url,'http://local').pathname;res.setHeader('Cache-Control','no-store');
if(path==='/activation'&&req.method==='GET'){
const snapshot=copy();trace.push({event:'read',snapshot});
if(holdNext){holdNext=false;held=()=>json(res,200,snapshot);}else json(res,200,snapshot);
}else if((path==='/activation'||path==='/unsafe')&&req.method==='PUT'){
let body='';for await(const chunk of req)body+=chunk;const next=JSON.parse(body);
// Single-process model: no await between revision comparison and replacement.
if(path==='/activation'&&req.headers['if-match']!==String(state.revision)){
trace.push({event:'conflict',expected:req.headers['if-match'],actual:state.revision});json(res,409,copy());return;
}
state={...next,revision:state.revision+1};trace.push({event:'commit',path,state:copy()});json(res,200,copy());
}else if(path==='/'){
const snapshot=copy();trace.push({event:'document',snapshot});res.setHeader('Content-Type','text/html');res.end(`<!doctype html><html lang="en"><title>Safe reload probe</title><body><p id="version"></p><label>Draft<textarea id="draft"></textarea></label><p id="status" role="status"></p><button id="stay">Keep working</button><button id="preserve">Preserve and reload</button><button id="discard">Discard draft and reload</button><script id="startup" type="application/json">${JSON.stringify(snapshot)}</script><script type="importmap">${JSON.stringify({imports:{reports:snapshot.reports}})}</script><script>window.observed={evaluations:[]};</script><script type="module" src="/reload-host.js"></script></body></html>`);
}else if(assets.has(path)){res.setHeader('Content-Type','application/javascript');res.end(assets.get(path));}
else{res.writeHead(404);res.end('missing');}
});
server.listen(0,'127.0.0.1');await once(server,'listening');const url=`http://127.0.0.1:${server.address().port}`;
const results=[];
async function put(value,rev,unsafe=false){const r=await fetch(url+(unsafe?'/unsafe':'/activation'),{method:'PUT',headers:{'Content-Type':'application/json','If-Match':String(rev)},body:JSON.stringify(value)});return {status:r.status,body:await r.json()};}
const get=()=>fetch(url+'/activation').then(r=>r.json());
async function control(id,run){reset();const r={id,kind:'HTTP-control-plane',status:'pass'};try{r.observation=await run();}catch(e){r.status='fail';r.error=e.message;}r.trace=[...trace];results.push(r);}
try{
await control('C01-blind-update-loses-neighbor',async()=>{const [a,b]=await Promise.all([get(),get()]);await put({...a,reports:'/reports-r2.js'},a.revision,true);await put({...b,account:'A2'},b.revision,true);assert.equal(state.reports,'/reports-r1.js');assert.equal(state.account,'A2');return {lostReportsUpdate:true};});
await control('C02-stale-disjoint-update-refetch',async()=>{const [a,b]=await Promise.all([get(),get()]);assert.equal((await put({...a,reports:'/reports-r2.js'},a.revision)).status,200);assert.equal((await put({...b,account:'A2'},b.revision)).status,409);const current=await get();assert.equal((await put({...current,account:'A2'},current.revision)).status,200);assert.equal(state.reports,'/reports-r2.js');assert.equal(state.account,'A2');return {bothUpdatesRetained:true,explicitRefetchAndReapply:true};});
await control('C03-same-feature-needs-decision',async()=>{const [a,b]=await Promise.all([get(),get()]);await put({...a,reports:'/reports-r2.js'},a.revision);const conflict=await put({...b,reports:'/reports-r1.js'},b.revision);assert.equal(conflict.status,409);assert.equal(state.reports,'/reports-r2.js');return {staleRollbackRejected:true,noAutomaticOverwrite:true};});
for(const[engine,launcher]of [['chromium',chromium],['firefox',firefox]]){
const browser=await launcher.launch({headless:true,...(engine==='chromium'&&process.env.FEP_CHROMIUM_PATH?{executablePath:process.env.FEP_CHROMIUM_PATH}:{})});
try{
async function scenario(id,run){reset();const p=await browser.newPage();p.setDefaultTimeout(2500);const errors=[];p.on('pageerror',e=>errors.push(e.message));const r={id,engine,browser:browser.version(),status:'pass'};
try{await p.goto(url);await p.waitForFunction(()=>window.ready);await p.locator('#draft').fill('unfinished proposal');await put({...state,reports:'/reports-r2.js'},state.revision);r.observation=await run(p);assert.deepEqual(errors,[]);}catch(e){r.status='fail';r.error=e.message;}
r.snapshot=await p.evaluate(()=>({draft:document.getElementById('draft').value,status:document.getElementById('status').textContent,version:document.getElementById('version').textContent,readOnly:document.getElementById('draft').readOnly,saved:sessionStorage.getItem('fep-review-draft')}));r.errors=errors;r.trace=[...trace];results.push(r);if(held){held();held=null;}await p.close();await writeFile('/tmp/fep-conflicts-reload-checkpoint.json',JSON.stringify({status:'incomplete',results},null,2)+'\n');}
async function reload(p,button){await Promise.all([p.waitForEvent('load'),p.locator(button).click()]);await p.waitForFunction(()=>window.ready);}
const draft= p=>p.locator('#draft').inputValue();
await scenario('R01-unguarded-reload-loses-draft',async p=>{await p.reload();await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'');assert.equal(await p.locator('#version').textContent(),'R2');return {draftLost:true,negativeControl:true};});
await scenario('R02-defer-keeps-work',async p=>{await p.locator('#stay').click();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {declinedReload:true};});
await scenario('R03-preserve-and-restore',async p=>{await reload(p,'#preserve');assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R2');const s=await p.evaluate(()=>JSON.parse(sessionStorage.getItem('fep-review-draft')));assert.equal(s.base,'record-v1');return {restored:true,basePreserved:true};});
await scenario('R04-storage-failure-blocks',async p=>{await p.evaluate(()=>window.failStorage=true);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'storage failed'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {reloadBlocked:true};});
await scenario('R05-schema-mismatch-blocks',async p=>{await put({...state,draftSchema:2},state.revision);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'incompatible draft schema'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {reloadBlocked:true};});
await scenario('R06-explicit-discard',async p=>{await p.evaluate(()=>sessionStorage.setItem('fep-review-draft',JSON.stringify({schema:1,text:'older saved draft',base:'record-v1'})));await reload(p,'#discard');assert.equal(await draft(p),'');assert.equal(await p.evaluate(()=>sessionStorage.getItem('fep-review-draft')),null);assert.equal(await p.locator('#version').textContent(),'R2');return {explicitlyDiscarded:true};});
await scenario('R07-unknown-write-blocks',async p=>{await p.evaluate(()=>window.pendingUnknown=true);await p.locator('#preserve').click();await p.locator('#status').filter({hasText:'unknown operation'}).waitFor();assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#version').textContent(),'R1');return {uncertainOutcomeNotDiscarded:true};});
await scenario('R08-edit-during-preflight',async p=>{holdNext=true;await p.locator('#preserve').click();await untilHeld();await p.locator('#draft').fill('newer proposal while checking');const release=held;held=null;const done=p.waitForEvent('load');release();await done;await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'newer proposal while checking');return {latestEditPreserved:true};});
await scenario('R09-activation-changes-after-check',async p=>{holdNext=true;await p.locator('#preserve').click();await untilHeld();await put({...state,draftSchema:2},state.revision);const release=held;held=null;const done=p.waitForEvent('load');release();await done;await p.waitForFunction(()=>window.ready);assert.equal(await draft(p),'unfinished proposal');assert.equal(await p.locator('#draft').getAttribute('readonly'),'');await p.locator('#status').filter({hasText:'Recovery required'}).waitFor();return {startupRevalidation:true,draftRetainedButNotEditable:true};});
}finally{await browser.close();}
}
}finally{if(held)held();await new Promise(resolve=>server.close(resolve));}
async function untilHeld(){for(let i=0;i<100;i++){if(held)return;await new Promise(r=>setTimeout(r,10));}throw new Error('preflight barrier not reached');}
const files={};for(const f of [...names,'conflicts-reload.mjs','package.json','package-lock.json'])files[f]=createHash('sha256').update(await readFile(dir+f)).digest('hex');const output=process.env.FEP_RESULT_PATH||'/tmp/fep-conflicts-reload.json';const result={date:new Date().toISOString(),node:process.version,files,results,passed:results.filter(x=>x.status==='pass').length,total:results.length};await writeFile(output,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify({output,passed:result.passed,total:result.total,failures:results.filter(x=>x.status==='fail')},null,2));if(result.passed!==result.total)process.exitCode=1;

View file

@ -0,0 +1,44 @@
const input = document.getElementById('draft');
const status = document.getElementById('status');
const key = 'fep-review-draft';
const startup = JSON.parse(document.getElementById('startup').textContent);
window.observed = {startup, outcomes: [], evaluations: [], restored: false};
window.pendingUnknown = false;
window.failStorage = false;
let blocked = false;
const saved = sessionStorage.getItem(key);
if (saved) {
const record = JSON.parse(saved);
input.value = record.text;
if (record.schema !== startup.draftSchema) {
blocked = true; input.readOnly = true; status.textContent = 'Recovery required: draft schema mismatch';
} else {window.observed.restored = true; status.textContent = 'Draft restored';}
}
if (!saved) status.textContent = 'Ready';
window.feature = await import('reports');
document.getElementById('version').textContent = window.feature.version;
window.ready = true;
window.requestReload = async choice => {
if (choice === 'stay') {status.textContent = 'Update deferred'; return false;}
if (blocked) {status.textContent = 'Recovery required: draft schema mismatch'; return false;}
if (window.pendingUnknown) {status.textContent = 'Resolve unknown operation outcome first'; return false;}
const latest = await fetch('/activation').then(r => r.json());
// Recheck after asynchronous work; edits made while checking must not be replaced by an old snapshot.
if (window.pendingUnknown) {status.textContent = 'Resolve unknown operation outcome first'; return false;}
if (choice !== 'discard' && latest.draftSchema !== 1) {
status.textContent = 'Update deferred: incompatible draft schema'; return false;
}
try {
if (window.failStorage) throw new Error('injected storage refusal');
if (choice === 'discard') sessionStorage.removeItem(key);
else {
const record = {schema: 1, text: input.value, base: 'record-v1'};
sessionStorage.setItem(key, JSON.stringify(record));
if (sessionStorage.getItem(key) !== JSON.stringify(record)) throw new Error('write verification failed');
}
} catch {status.textContent = 'Update deferred: draft storage failed'; return false;}
location.reload();return true;
};
document.getElementById('stay').onclick = () => window.requestReload('stay');
document.getElementById('preserve').onclick = () => window.requestReload('preserve');
document.getElementById('discard').onclick = () => window.requestReload('discard');