// scripts/stack-release.sh (D1, #1659). Design, the T table and the mutants:
// agents/darkwing/work/queue-162/BUILD.md. Every run uses a temp data root, temp units and stand-in
// systemctl, journalctl and systemd-analyze on PATH; nothing here reads ~/.mosaic-dev or a live unit.
import test, {after} from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync,spawn,spawnSync} from 'node:child_process';
import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,appendFileSync,cpSync,rmSync,chmodSync,existsSync,statSync,lstatSync,symlinkSync,linkSync,readdirSync,realpathSync} from 'node:fs';
import {createServer} from 'node:http';
import {createHash} from 'node:crypto';
import {DatabaseSync} from 'node:sqlite';
import {tmpdir} from 'node:os';
import {join,dirname,basename} from 'node:path';
import {fileURLToPath,pathToFileURL} from 'node:url';
import {once} from 'node:events';

const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
const NODE=process.execPath;
const MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)';
const BUS='mosaic-bus@acme.service',CON='mosaic-webui.service';
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
const script=(fn)=>`#!${NODE}\n(${fn})();\n`;

// --- Stand-ins. Each is serialized into its own file, so it uses only process and its own scope. ---

// systemctl --user: unit state in $SD_STATE/units.json, every call in calls.log. A bus start runs
// holder.cjs (the store held open in WAL mode, writer.lock, host.json) and, with --pm in the unit's
// ExecStart, writes the `launched` journal line and the launches entry unless a pm-<copy> fault
// names another case. Faults are files in $SD_STATE/faults named for the copy they hit. Units of a
// named instance (row 190) run on their own data root and host files beside the default's.
function systemctl(){
 const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'),crypto=process.getBuiltinModule('node:crypto');
 const sd=process.env.SD_STATE,data=process.env.SD_DATA,unitDir=path.join(process.env.XDG_CONFIG_HOME,'systemd','user');
 const args=process.argv.slice(2).filter((a)=>a!=='--user'),[verb,...rest]=args;
 fs.appendFileSync(path.join(sd,'calls.log'),args.join(' ')+'\n');
 const faultFile=(n)=>path.join(sd,'faults',n),fault=(n)=>fs.existsSync(faultFile(n));
 const stFile=path.join(sd,'units.json');
 const load=()=>{try{return JSON.parse(fs.readFileSync(stFile,'utf8'))}catch{return {}}};
 const save=(s)=>{fs.writeFileSync(stFile+'.tmp',JSON.stringify(s));fs.renameSync(stFile+'.tmp',stFile)};
 const sleep=(ms)=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);
 const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
 // Row 190: a bus unit is the default's mosaic-bus@B or instance NAME's mosaic-bus-NAME@B; %i is B.
 const isBus=(u)=>/^mosaic-bus(-[a-z][a-z0-9]{1,15})?@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice(u.indexOf('@')+1,-'.service'.length);
 const unitFile=(u)=>{
  const main=isBus(u)?u.replace(/@[^@]*$/,'@.service'):u,file=path.join(unitDir,main);
  if(!fs.existsSync(file))return null;
  // The drop-in directories in systemd's order: service.d, the name cut after each hyphen, then
  // the template's and the unit's own.
  const stem=main.slice(0,main.search(/@?\.service$/)),dirs=['service.d'],drops=[];
  for(let i=0;i<stem.length;i++)if(stem[i]==='-')dirs.push(stem.slice(0,i+1)+'.service.d');
  for(const d of [...dirs,...(isBus(u)?[main+'.d',u+'.d']:[u+'.d'])]){const dir=path.join(unitDir,d);if(fs.existsSync(dir))for(const f of fs.readdirSync(dir).sort())if(f.endsWith('.conf'))drops.push(path.join(dir,f))}
  const r={wd:'',exec:'',env:[],drops};
  for(const f of [file,...drops])for(let line of fs.readFileSync(f,'utf8').split('\n')){
   line=line.replace(/%i/g,isBus(u)?inst(u):'').replace(/%h/g,process.env.HOME);
   const m=/^(WorkingDirectory|ExecStart|Environment)=(.*)$/.exec(line);if(!m)continue;
   if(m[1]==='WorkingDirectory')r.wd=m[2];else if(m[1]==='ExecStart'){if(m[2])r.exec=m[2]}else r.env.push(m[2]);
  }
  return r;
 };
 // A named instance's unit sets MOSAIC_CONFIG, and its bus works on that config's data root. A unit
 // without it is the default's, on $SD_DATA. Each data root has its own host and launches files.
 const dataOf=(f)=>{const c=f&&f.env.find((v)=>v.startsWith('MOSAIC_CONFIG='));return c?JSON.parse(fs.readFileSync(c.slice('MOSAIC_CONFIG='.length),'utf8')).dataRoot:data};
 const perData=(d,n)=>path.join(sd,d===data?`${n}.json`:`${n}-${path.basename(d)}.json`);
 const snap=(ev,d)=>{
  const dir=path.join(d,'bus'),files={};
  try{for(const n of fs.readdirSync(dir).sort()){const p=path.join(dir,n),st=fs.statSync(p);if(st.isFile())files[n]=[st.size,st.mtimeMs,crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex')]}}catch{}
  fs.appendFileSync(path.join(sd,'snap.jsonl'),JSON.stringify({ev,files})+'\n');
 };
 const pm=(unit,d,id,pid,off)=>{
  let mode='';try{mode=fs.readFileSync(faultFile(`pm-${id}`),'utf8').trim()}catch{}
  const b=inst(unit),now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`;
  const line=(text)=>fs.appendFileSync(path.join(sd,'journal.jsonl'),JSON.stringify({epoch,unit,text})+'\n');
  const ok=()=>line(`launched pm-${b}: run ${run}, claude opus`);
  const lf=perData(d,'launches');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{}
  const entry=(o)=>list.push({run,business:b,live:true,startedAt:at,...o});
  line(`bus host up: business ${b}, socket /fixture/bus.sock, notifier off`);
  switch(mode){
   case 'none':break;
   case 'two':ok();line(`launched pm-${b}: run ${run}b, claude opus`);entry({});break;
   case 'badword':line(`launched pm-${b}: job ${run}, claude opus`);entry({});break;
   case 'notlisted':ok();break;
   case 'early':ok();entry({startedAt:new Date(now-3600e3).toISOString()});break;
   case 'human':entry({run:`human-${pid}`});break;
   case 'other':ok();entry({business:'other'});break;
   case 'dead':ok();entry({live:false});break;
   default:ok();entry({});
  }
  fs.writeFileSync(lf,JSON.stringify(list));
 };
 if(verb==='show'){
  const u=rest[0],props=[];for(let i=1;i<rest.length;i++)if(rest[i]==='-p')props.push(rest[++i]);
  const f=unitFile(u),st=load()[u]||{},id=f?path.basename(f.wd):'';
  for(const p of props){
   if(p==='LoadState')console.log(`LoadState=${f?'loaded':'not-found'}`);
   else if(p==='WorkingDirectory')console.log(`WorkingDirectory=${f?f.wd+(!isBus(u)&&fault(`check-${id}`)?'-moved':''):''}`);
   else if(p==='ExecStart')console.log(f&&f.exec?`ExecStart={ path=${f.exec.split(' ')[0]} ; argv[]=${f.exec} ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }`:'ExecStart=');
   else if(p==='Environment')console.log(`Environment=${f?f.env.join(' '):''}`);
   else if(p==='MainPID')console.log(`MainPID=${st.pid||0}`);
   else if(p==='DropInPaths')console.log(`DropInPaths=${f?f.drops.join(' '):''}`);
  }
  process.exit(0);
 }
 if(verb==='is-active'){
  const q=rest[0]==='--quiet',u=q?rest[1]:rest[0];let s=(load()[u]||{}).state||'inactive';
  // bus-stops-after N (T29): the next N bus checks answer the real state, every later one answers
  // inactive, and units.json stays as it is, as if the bus stopped between two checks.
  if(isBus(u)&&fault('bus-stops-after')){const n=Number(fs.readFileSync(faultFile('bus-stops-after'),'utf8'));if(n>0)fs.writeFileSync(faultFile('bus-stops-after'),String(n-1));else s='inactive'}
  if(!q)console.log(s);process.exit(s==='active'?0:3);
 }
 if(verb==='is-enabled'){console.log('disabled');process.exit(1)}
 if(verb==='daemon-reload')process.exit(0);
 if(verb==='list-units'){
  if(fault('list-fails'))process.exit(1);
  const pats=rest.filter((a)=>!a.startsWith('--')).map((p)=>new RegExp('^'+p.replace(/\./g,'\\.').replace(/\*/g,'.*')+'$'));
  for(const [u,s] of Object.entries(load()))if(pats.some((re)=>re.test(u)))console.log(`${u} loaded ${s.state} ${s.state==='active'?'running':'dead'} fixture`);
  process.exit(0);
 }
 if(verb==='start'){
  const u=rest[0],f=unitFile(u);if(!f){console.error(`Unit ${u} not found.`);process.exit(5)}
  const s=load(),id=path.basename(f.wd),d=dataOf(f);
  if(!isBus(u)){s[u]={state:'active',pid:null,wd:f.wd};save(s);process.exit(0)}
  if(fault(`start-dies-${id}`)){s[u]={state:'failed',pid:null,wd:f.wd};save(s);process.exit(0)}
  // Row 185 (T48): the copy's start writes one event before the holder opens the store, so the
  // store differs from a backup the run took before the start. The close leaves no -wal.
  if(fault(`start-writes-row-${id}`)){
   const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite'));
   db.prepare('INSERT INTO events (at) VALUES (?)').run('2999-01-01T00:00:00.000Z');db.close();
  }
  snap('start',d);
  const off=process.env.SD_CLOCK_OFFSET||'0';
  // The holder must not inherit stack-release.sh's lock (fd 9) or any other fd above 2.
  const close='for fd in /proc/self/fd/*; do n=${fd##*/}; [ "$n" -gt 2 ] && eval "exec $n>&-" 2>/dev/null; done; exec "$@"';
  const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),d,inst(u),sd,off,perData(d,'host')],{detached:true,stdio:'ignore'});
  ch.unref();
  fs.appendFileSync(path.join(sd,'pids.log'),ch.pid+'\n');
  const ready=path.join(sd,`ready-${ch.pid}`),end=Date.now()+10000;
  while(!fs.existsSync(ready)){if(Date.now()>end){console.error('fixture holder did not start');process.exit(1)}sleep(20)}
  s[u]={state:'active',pid:ch.pid,wd:f.wd};save(s);
  if(f.exec.split(' ').slice(1).includes('--pm'))pm(u,d,id,ch.pid,Number(off));
  process.exit(0);
 }
 if(verb==='stop'){
  const u=rest[0],s=load(),st=s[u];
  if(st&&st.pid){
   try{process.kill(st.pid,'SIGTERM')}catch{}
   const end=Date.now()+10000;while(alive(st.pid)&&Date.now()<end)sleep(20);
   if(alive(st.pid))try{process.kill(st.pid,'SIGKILL')}catch{}
  }
  if(st){s[u]={...st,state:'inactive',pid:null};save(s)}
  if(isBus(u)){
   const d=dataOf(unitFile(u));
   // One-shot: the stop leaves an empty -wal or -shm beside the store, as an unclean close would.
   for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(d,'bus','bus.sqlite'+ext),'')}
   // One-shot (T45): the stop writes one row after any STAMP into the table the fault names, as a
   // write the bus made between rollback's count and its stop would. The close leaves no -wal.
   if(fault('stop-writes-row')){
    const t=fs.readFileSync(faultFile('stop-writes-row'),'utf8');fs.rmSync(faultFile('stop-writes-row'));
    const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite'));
    db.exec(`CREATE TABLE IF NOT EXISTS ${t} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);
    db.prepare(`INSERT INTO ${t} (at) VALUES (?)`).run('2999-01-01T00:00:00.000Z');db.close();
   }
   snap('stop',d);
  }
  process.exit(0);
 }
 console.error(`fixture systemctl: unsupported: ${args.join(' ')}`);process.exit(1);
}

// journalctl --user -u UNIT --since @EPOCH -o cat. A bare local time gets exit 1, as M35 needs.
function journalctl(){
 const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
 const sd=process.env.SD_STATE,a=process.argv.slice(2);let unit='',since='';
 for(let i=0;i<a.length;i++){if(a[i]==='-u')unit=a[++i];else if(a[i]==='--since')since=a[++i]}
 fs.appendFileSync(path.join(sd,'journal-since.log'),since+'\n');
 if(fs.existsSync(path.join(sd,'faults','journal-fails')))process.exit(1);
 let from;
 if(/^@\d+$/.test(since))from=Number(since.slice(1));
 else{const m=/^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) UTC$/.exec(since);if(!m)process.exit(1);from=Date.parse(`${m[1]}T${m[2]}Z`)/1000}
 let lines=[];try{lines=fs.readFileSync(path.join(sd,'journal.jsonl'),'utf8').split('\n').filter(Boolean).map((l)=>JSON.parse(l))}catch{}
 for(const o of lines)if(o.unit===unit&&o.epoch>=from)console.log(o.text);
}

function systemdAnalyze(){
 const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
 if(process.argv[2]!=='--user'||process.argv[3]!=='unit-paths')process.exit(1);
 console.log(path.join(process.env.XDG_CONFIG_HOME,'systemd','user'));
 // The unit-path fault adds its text as one more path, as T28's relative path needs.
 const extra=path.join(process.env.SD_STATE,'faults','unit-path');
 if(fs.existsSync(extra))console.log(fs.readFileSync(extra,'utf8'));
}

// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does. It writes its
// status to the host file systemctl names for its data root.
function holder(){
 const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
 const [data,business,sd,offset,host]=process.argv.slice(2),bus=path.join(data,'bus');
 fs.mkdirSync(bus,{recursive:true,mode:0o700});
 const db=new DatabaseSync(path.join(bus,'bus.sqlite'));
 if(!db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='meta'").get().n){
  db.exec('PRAGMA journal_mode=WAL');
  db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
  db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
  db.prepare("INSERT INTO meta VALUES ('schema_version', '3g')").run();
 }
 fs.writeFileSync(path.join(bus,'writer.lock'),String(process.pid));
 const at=new Date(Date.now()-Number(offset||0)*1000).toISOString();
 fs.writeFileSync(host+'.tmp',JSON.stringify({host:{business,pid:process.pid,live:true,startedAt:at,notifier:'ok'},boot:{ok:true,business,at}}));
 fs.renameSync(host+'.tmp',host);
 process.on('SIGTERM',()=>{db.close();fs.rmSync(path.join(bus,'writer.lock'),{force:true});process.exit(0)});
 fs.writeFileSync(path.join(sd,`ready-${process.pid}`),'');
 setInterval(()=>{},1<<30);
}

// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote
// for the data root of the config it runs under (row 190), the default's being $SD_DATA.
function mosaicStub(){
 const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
 const id=path.basename(path.resolve(__dirname,'..')),sd=process.env.SD_STATE,a=process.argv.slice(2).join(' ');
 const fault=(n)=>fs.existsSync(path.join(sd,'faults',n));
 let d=process.env.SD_DATA;try{d=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot}catch{}
 const perData=(n)=>path.join(sd,d===process.env.SD_DATA?`${n}.json`:`${n}-${path.basename(d)}.json`);
 const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
 if(a==='bus status --json'){
  if(fault(`status-${id}`))process.exit(1);
  let s={host:null,boot:null};
  try{s=JSON.parse(fs.readFileSync(perData('host'),'utf8'));s.host.live=alive(s.host.pid)}catch{}
  console.log(JSON.stringify(s));
 }else if(a==='bus status')console.log(`bus: fixture status for ${id}`);
 else if(a==='launches list --json'){
  if(fault(`launches-${id}`))process.exit(1);
  let l=[];try{l=JSON.parse(fs.readFileSync(perData('launches'),'utf8'))}catch{}
  console.log(JSON.stringify(l));
 }else{console.error(`fixture mosaic: ${a}`);process.exit(4)}
}

// A copy's migrate-schema.mjs: records the backups it saw, then sets 3h, or 3h-partial and exit 1.
function migrateStub(){
 const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
 const data=process.argv[2],sd=process.env.SD_STATE,dir=path.join(data,'backups','stack');
 const backups=fs.existsSync(dir)?fs.readdirSync(dir).sort().map((name)=>({name,sums:fs.existsSync(path.join(dir,name,'SHA256SUMS'))})):[];
 fs.writeFileSync(path.join(sd,'migrate.json'),JSON.stringify({backups}));
 const fail=fs.existsSync(path.join(sd,'faults','migrate-fails'));
 const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));
 db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(fail?'3h-partial':'3h');db.close();
 if(fail)process.exit(1);
 console.log('fixture migrate: 3h');
}

// --- The module fixture: one repo, its copies and the stand-ins, shared by every test. ---

const base=realpathSync(mkdtempSync(join(tmpdir(),'stack-release-')));
after(()=>{execFileSync('chmod',['-R','u+w',base]);rmSync(base,{recursive:true,force:true})});
const repo=join(base,'repo'),COPIES=join(base,'copies'),SBIN=join(base,'sbin');
mkdirSync(repo);mkdirSync(COPIES);mkdirSync(SBIN);
writeFileSync(join(base,'package.json'),'{"type":"commonjs"}\n');
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
const pkg={name:'copy-fixture',version:'1.0.0'};
writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:pkg.name,version:pkg.version,lockfileVersion:3,packages:{'':pkg}}));
git(repo,'init','-q','-b','refactor');git(repo,'config','gc.auto','0');git(repo,'add','.');git(repo,'commit','-qm','fixture');
const commit=(msg)=>{git(repo,'commit','-q','--allow-empty','-m',msg);return git(repo,'rev-parse','HEAD')};
// refactor: F A B C1 C, then M merging side branch S (from A). U sits on a branch nothing reaches.
const F=git(repo,'rev-parse','HEAD'),A=commit('row 901: A'),B=commit('row 902: B'),C1=commit('row 903: C1'),C=commit('row 904: C');
git(repo,'checkout','-q','-b','side',A);const S=commit('row 905: S');
git(repo,'checkout','-q','-b','unreached',A);const U=commit('row 906: U');
git(repo,'checkout','-q','refactor');git(repo,'merge','-q','--no-ff','-m','merge side','side');
const M=git(repo,'rev-parse','HEAD');git(repo,'update-ref','refs/remotes/origin/refactor',M);
// `to` false leaves out the `to:` lines (C1); a string puts that value in each one (A). The --plan
// steps line can't be read from either (T27).
const storeText=(v,froms,to=true)=>`export const SCHEMA_VERSION = '${v}';\nexport const MIGRATIONS = [\n${froms.map((f,i)=>`  {\n    from: '${f}',\n${to===false?'':`    to: '${to===true?froms[i+1]??v:to}',\n`}    run() {},\n  },\n`).join('')}];\n`;
// Plug-in kinds (row 169): C1 and C know the harness kind, as a copy at or past c216f47db does. The
// rest have it under another name, as ca15d73e9 has no harness kind. Every copy has the real business
// loader and roles, which the tool imports to read the business file.
const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8');
const KINDS_OLD=KINDS_NEW.replace('\n  harness: Object.freeze({','\n  "harness-before-c216f47db": Object.freeze({');
assert.notEqual(KINDS_OLD,KINDS_NEW);
for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){
 const d=join(COPIES,id);
 for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?"// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n":'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){
  mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
 }
 for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
 execFileSync('chmod',['-R','a-w',d]);
}
for(const [name,fn] of [['systemctl',systemctl],['journalctl',journalctl],['systemd-analyze',systemdAnalyze]])writeFileSync(join(SBIN,name),script(fn),{mode:0o755});
writeFileSync(join(base,'holder.cjs'),`(${holder})();\n`);
const SR=join(repo,'scripts','stack-release.sh');

// --- Per-test roots and helpers ---

const sha=(f)=>createHash('sha256').update(readFileSync(f)).digest('hex');
const lines=(f)=>existsSync(f)?readFileSync(f,'utf8').split('\n').filter(Boolean):[];
const calls=(e)=>lines(join(e.sd,'calls.log'));
const startsStops=(e,from)=>calls(e).slice(from).filter((c)=>/^(start|stop) /.test(c));
const logLines=(e)=>lines(e.log).map((l)=>JSON.parse(l));
const pointer=(e)=>existsSync(e.pointer)?JSON.parse(readFileSync(e.pointer,'utf8')):null;
const units=(e)=>JSON.parse(readFileSync(join(e.sd,'units.json'),'utf8'));
const snaps=(e)=>lines(join(e.sd,'snap.jsonl')).map((l)=>JSON.parse(l));
const fault=(e,name,text='')=>writeFileSync(join(e.sd,'faults',name),text);
const sysctl=(e,...a)=>execFileSync(join(SBIN,'systemctl'),['--user',...a],{env:e.env,encoding:'utf8'});
const edit=(f,from,to)=>{const s=readFileSync(f,'utf8');assert.ok(s.includes(from),`${f} has no ${from}`);writeFileSync(f,s.replace(from,to))};
const posts=(e)=>readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-'));
const backupSum=(e,act)=>readFileSync(join(e.data,'backups','stack',act,'SHA256SUMS'),'utf8').split(' ')[0];
const commitLines=(range)=>git(repo,'log','--oneline','--no-merges','--max-count=200',range).split('\n').map((l)=>`  ${l}`);
// The units, the wrapper, the pointer and the store, by sha256.
const fp=(e)=>({bus:sha(join(e.units,e.tmpl)),console:sha(join(e.units,e.con)),wrapper:sha(join(e.bin,e.wrapper)),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)});
// The schema version: readOnly while the holder has the store open, immutable when it is stopped.
function storeVersion(file,held){
 const db=held?new DatabaseSync(file,{readOnly:true}):new DatabaseSync(pathToFileURL(file).href+'?immutable=1',{readOnly:true});
 try{return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value}finally{db.close()}
}
// Every file and directory under p with size and mtime: T23 and T16 compare it before and after.
function tree(p){
 const out=[];
 const walk=(q)=>{let st;try{st=statSync(q)}catch{return}out.push(st.isDirectory()?`${q}/`:`${q} ${st.size} ${st.mtimeMs}`);if(st.isDirectory())for(const n of readdirSync(q).sort())walk(join(q,n))};
 walk(p);return out;
}
// T17: what a run may never remove.
const kept=(e)=>[...tree(join(e.data,'backups','stack')),...tree(join(e.state,'stack-rollback')),...posts(e).flatMap((n)=>tree(join(e.data,n))),...tree(COPIES)].map((l)=>l.replace(/ .*/,''));

// The business file (row 169): the committed example made loadable, as acme, with `plugins` when given.
const EXAMPLE=readFileSync(join(repo,'packages','business','examples','mosaic-stack.example.json'),'utf8');
function biz(e,plugins){
 const b=JSON.parse(EXAMPLE);let n=0;
 const walk=(o)=>{for(const [k,v] of Object.entries(o)){if(k==='botId')o[k]=++n;else if(v==='YYYY-MM-DD')o[k]='2027-01-01';else if(v&&typeof v==='object')walk(v)}};
 walk(b);b.id='acme';if(plugins!==undefined)b.plugins=plugins;
 const f=join(dirname(e.cfg),'businesses','acme.json');mkdirSync(dirname(f),{recursive:true});
 writeFileSync(f,JSON.stringify(b,null,2)+'\n',{mode:0o600});chmodSync(f,0o600);
}

async function mk(t){
 const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-')));
 const e={root,data:join(root,'data'),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')};
 // The default instance's names; named() sets NAME's (row 190).
 Object.assign(e,{flags:[],bus:BUS,tmpl:'mosaic-bus@.service',con:CON,wrapper:'mosaic'});
 Object.assign(e,{state:join(e.data,'state'),pointer:join(e.data,'state','stack-active.json'),log:join(e.data,'state','stack-activation-log.jsonl'),store:join(e.data,'bus','bus.sqlite'),lock:join(e.data,'state','stack-release.lock')});
 for(const d of [join(root,'home'),dirname(e.cfg),join(e.data,'bus'),e.bin,dirname(e.claude),join(e.sd,'faults'),e.units,join(root,'xdata')])mkdirSync(d,{recursive:true});
 chmodSync(join(e.data,'bus'),0o700);
 writeFileSync(e.cfg,JSON.stringify({configVersion:1,environment:'development',dataRoot:e.data,execution:{backend:'docker',provider:'zai',model:'glm-5.3-flash',adapter:'mock'}}));
 biz(e);
 writeFileSync(join(e.bin,'mosaic'),`#!/usr/bin/env bash\n${MARKER}\n[ "\${1:-}" = __lease-capability ] && exec "${e.bin}/mosaic-old" "$@"\nexec "/old/scripts/mosaic" "$@"\n`,{mode:0o755});
 writeFileSync(join(e.bin,'mosaic-old'),'#!/bin/sh\nexit 0\n',{mode:0o755});
 writeFileSync(e.claude,'#!/bin/sh\necho "9.9.9 (Claude Code fixture)"\n',{mode:0o755});
 e.env={PATH:`${SBIN}:${dirname(NODE)}:/usr/bin:/bin`,HOME:join(root,'home'),XDG_CONFIG_HOME:join(root,'xcfg'),XDG_DATA_HOME:join(root,'xdata'),MOSAIC_CONFIG:e.cfg,SD_STATE:e.sd,SD_DATA:e.data,TMPDIR:tmpdir()};
 const server=await healthz(e);
 t.after(()=>{
  for(const pid of lines(join(e.sd,'pids.log')).map(Number)){
   try{if(readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(join(base,'holder.cjs')))process.kill(pid,'SIGKILL')}catch{}
  }
  for(const s of [server,...(e.servers??[])])s.close();
  execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
 });
 return e;
}

// The Console's /healthz on e.port: 200 while the stand-in has e.con active and no healthz-<copy>
// fault names its copy.
async function healthz(e){
 const server=createServer((q,r)=>{
  let st={};try{st=units(e)[e.con]||{}}catch{}
  const ok=q.url==='/healthz'&&st.state==='active'&&!existsSync(join(e.sd,'faults',`healthz-${basename(st.wd||'')}`));
  r.writeHead(ok?200:500);r.end();
 });
 server.listen(0,'127.0.0.1');await once(server,'listening');e.port=String(server.address().port);
 return server;
}

// Row 190: stack instance NAME on e's host. It shares e's stand-ins, unit directory and $BIN, and has
// its own config and data root from bootstrap --instance, its own Console port and business acme.
// Its runs pass --instance NAME and no MOSAIC_CONFIG.
async function named(e,name){
 const env={...e.env};delete env.MOSAIC_CONFIG;
 execFileSync('bash',[join(repo,'scripts','bootstrap.sh'),'--instance',name],{env,stdio:'ignore'});
 const data=join(env.HOME,`.mosaic-${name}`);
 const n={...e,env,data,cfg:join(env.HOME,'.config',`mosaic-${name}`,'config.json'),flags:['--instance',name],bus:`mosaic-bus-${name}@acme.service`,tmpl:`mosaic-bus-${name}@.service`,con:`mosaic-webui-${name}.service`,wrapper:`mosaic-${name}`,name};
 Object.assign(n,{state:join(data,'state'),pointer:join(data,'state','stack-active.json'),log:join(data,'state','stack-activation-log.jsonl'),store:join(data,'bus','bus.sqlite'),lock:join(data,'state','stack-release.lock')});
 mkdirSync(join(data,'bus'),{mode:0o700});
 biz(n);
 (e.servers??=[]).push(await healthz(n));
 return n;
}

async function run(e,...args){
 const before=existsSync(e.data)?kept(e):[];
 const ch=spawn('bash',[e.sr??SR,...args,...e.flags,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
 let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
 const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
 const [code]=await once(ch,'close');clearTimeout(timer);
 const now=new Set(kept(e));
 for(const p of before)assert.ok(now.has(p),`T17: ${args.join(' ')} removed ${p}`);
 return {code,out,err};
}

// The stack on copy `copy` for business acme, both units active. pointer: adopt (a real adopt and
// its log line), hand (written here, no log) or none. Snapshots start after the seed.
async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
 const db=new DatabaseSync(e.store);
 db.exec('PRAGMA journal_mode=WAL');
 db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
 db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
 db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
 for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
 db.close();
 const sh=(name,...a)=>execFileSync('bash',[join(repo,'scripts',name),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']});
 sh('bus-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
 sh('webui-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
 sh('stack-copy.sh','wrapper',copy,...e.flags,'--copies',COPIES,'--bin',e.bin);
 // The seeded host started two minutes ago, so its boot status and PM run predate any run's start.
 execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
 sysctl(e,'start',e.con);
 if(how==='adopt'){const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.err)}
 else if(how==='hand'){
  mkdirSync(e.state,{recursive:true});
  writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g',activation:'manual',at:'2026-10-18T00:00:00Z'})+'\n');
 }
 writeFileSync(join(e.sd,'snap.jsonl'),'');
}

const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
const planNoBackup='backup: none; the run rolls back before the backup';
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];

// --- T-I8 one-shot (row 190): the default instance's renders and outputs from the base da230ca48
// scripts and templates and from the candidate, in one fixture. Not committed; BUILD.md records it.
const BASE_SHA='da230ca48';
const repoBase=join(base,'repo-base');
cpSync(repo,repoBase,{recursive:true});
const changed=['packages/cli/systemd/mosaic-bus.service.in','packages/conversation/src/guard.mjs','packages/discord/systemd/mosaic-discord@.service.in','packages/webui/systemd/mosaic-webui.service.in','scripts/bootstrap.sh','scripts/bus-service.sh','scripts/discord-service.sh','scripts/mosaic-config.mjs','scripts/stack-copy.sh','scripts/stack-release.sh','scripts/webui-service.sh'];
for(const f of changed)writeFileSync(join(repoBase,f),execFileSync('git',['-C',ROOT,'show',`${BASE_SHA}:${f}`]));
rmSync(join(repoBase,'scripts','instance.sh'));
for(const f of changed)assert.notEqual(sha(join(repoBase,f)),sha(join(repo,f)),f);
const SR_BASE=join(repoBase,'scripts','stack-release.sh');
const both=async(e,...args)=>{const c=await run({...e,sr:SR},...args),b=await run({...e,sr:SR_BASE},...args);return [c,b]};
const sh2=(r,e,name,...a)=>spawnSync('bash',[join(r,'scripts',name),...a],{env:e.env,encoding:'utf8'});
const OUT=process.env.TI8_OUT;
const record=(label,c,b)=>appendFileSync(OUT,`== ${label}\ncandidate code ${c.code??c.status} sha ${createHash('sha256').update(c.out??c.stdout).digest('hex')} err ${createHash('sha256').update(c.err??c.stderr).digest('hex')}\nbase      code ${b.code??b.status} sha ${createHash('sha256').update(b.out??b.stdout).digest('hex')} err ${createHash('sha256').update(b.err??b.stderr).digest('hex')}\n`);
// The one path that differs by design: each script names its own repository (--checkout, REPO).
const norm=(r)=>({code:r.code??r.status,out:(r.out??r.stdout).replaceAll(repoBase,repo),err:(r.err??r.stderr).replaceAll(repoBase,repo)});
const same=(label,c0,b0)=>{const c=norm(c0),b=norm(b0);record(label,c,b);assert.equal(c.code??c.status,b.code??b.status,label);assert.equal(c.out??c.stdout,b.out??b.stdout,label);assert.equal(c.err??c.stderr,b.err??b.stderr,label)};

test('T-I8 one-shot: the default instance prints and renders the same from the base and the candidate',async(t)=>{
 const e=await mk(t);await seed(e);
 for(const [name,args] of [
  ['bus-service.sh',['render','--copy',B,'--copies',COPIES,'--claude',e.claude]],
  ['bus-service.sh',['render','--copy',B,'--copies',COPIES,'--pm','--claude',e.claude]],
  ['webui-service.sh',['render','--copy',B,'--copies',COPIES,'--port',e.port,'--business','acme']],
  ['webui-service.sh',['render','--copy',B,'--copies',COPIES,'--port',e.port,'--business','acme','--tester-port','7341']],
  ['discord-service.sh',['render']],
  ['stack-copy.sh',['check','--copies',COPIES,'--bin',e.bin]],
  ['stack-copy.sh',['check','--copies',COPIES,'--bin',e.bin,'--business','acme']],
 ]){
  const c=sh2(repo,e,name,...args),b=sh2(repoBase,e,name,...args);
  assert.equal(c.status,0,`${name} ${args.join(' ')}: ${c.stderr}`);
  same(`${name} ${args.join(' ')}`,c,b);
 }
 for(const args of [['status','acme'],['activate',C1,'acme','--plan'],['activate',C1,'acme','--tester-port','7341','--plan'],['activate',C,'acme','--migrate','--plan'],['activate',B,'acme','--plan'],['rollback','acme','--plan']]){
  const [c,b]=await both(e,...args);same(args.join(' '),c,b);
 }
 assert.equal((await run(e,'activate',C1,'acme')).code,0);
 for(const args of [['status','acme'],['rollback','acme','--plan']]){const [c,b]=await both(e,...args);same(`after activate C1: ${args.join(' ')}`,c,b)}
});
