git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
459 lines
34 KiB
JavaScript
459 lines
34 KiB
JavaScript
// The stack-release stand-ins and module fixture, shared by stack-release.test.mjs and
|
|
// stack-release-external.test.mjs (row 232). Moved unchanged from stack-release.test.mjs, plus
|
|
// what the external tests need; each test file runs in its own process, so each gets its own base.
|
|
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';
|
|
import {organizationImportedFiles} from './organization-imports.mjs';
|
|
|
|
export const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
|
|
export const NODE=process.execPath;
|
|
export const MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)';
|
|
export const BUS='[email protected]',CON='mosaic-webui.service';
|
|
export 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();
|
|
export 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.
|
|
export 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');
|
|
// Row 232: argv.log keeps each call as given, --user and all, for the user/external argv tests.
|
|
fs.appendFileSync(path.join(sd,'argv.log'),process.argv.slice(2).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={file,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{}
|
|
// instance is launch.by's, chief in the example business, as the external PM check reads (row 232).
|
|
const entry=(o)=>list.push({run,instance:'chief',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;
|
|
// Row 232: two live entries for the PM's instance, and one for it beside a live coder run.
|
|
case 'twolive':ok();entry({});entry({run:`${run}b`});break;
|
|
case 'coder':ok();entry({});entry({run:`${run}c`,instance:'coder'});break;
|
|
default:ok();entry({});
|
|
}
|
|
fs.writeFileSync(lf,JSON.stringify(list));
|
|
};
|
|
if(verb==='show'){
|
|
// `show [--timestamp=unix] UNIT -p P...` and `show -p P UNIT`, as the external path calls it.
|
|
let u='';const props=[];for(let i=0;i<rest.length;i++){if(rest[i]==='-p')props.push(rest[++i]);else if(!rest[i].startsWith('--'))u=rest[i]}
|
|
const f=unitFile(u),st=load()[u]||{},id=f?path.basename(f.wd):'';
|
|
// ActiveEnterTimestamp (row 232) is the start's second, or what the enter fault says: a JSON
|
|
// {value} or {value,at,then}, which answers then from epoch ms at on.
|
|
const enter=()=>{
|
|
if(!fault('enter'))return st.enter;
|
|
const o=JSON.parse(fs.readFileSync(faultFile('enter'),'utf8'));return o.at!==undefined&&Date.now()>=o.at?o.then:o.value;
|
|
};
|
|
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(' '):''}`);
|
|
else if(p==='FragmentPath')console.log(`FragmentPath=${f&&!fault('no-fragment')?f.file:''}`);
|
|
else if(p==='ActiveEnterTimestamp'){const v=st.state==='active'?enter():undefined;console.log(`ActiveEnterTimestamp=${v===undefined?'':'@'+v}`)}
|
|
}
|
|
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);
|
|
const enter=Math.floor((Date.now()-Number(process.env.SD_CLOCK_OFFSET||'0')*1000)/1000);
|
|
if(!isBus(u)){s[u]={state:'active',pid:null,wd:f.wd,enter};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,enter};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.
|
|
export 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);
|
|
}
|
|
|
|
export 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.
|
|
export 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.
|
|
export 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);
|
|
// Row 232: status-until-<copy> fails bus status until the epoch ms it holds.
|
|
const until=path.join(sd,'faults',`status-until-${id}`);
|
|
if(fs.existsSync(until)&&Date.now()<Number(fs.readFileSync(until,'utf8')))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.
|
|
export 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. ---
|
|
|
|
export const base=realpathSync(mkdtempSync(join(tmpdir(),'stack-release-')));
|
|
after(()=>{execFileSync('chmod',['-R','u+w',base]);rmSync(base,{recursive:true,force:true})});
|
|
export 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');
|
|
// A linked node_modules (a deploy tree) is not a directory to .gitignore's node_modules/; npm ci makes the copy's own.
|
|
export 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',':(exclude,glob)**/node_modules'],{encoding:'utf8'}).trim().split('\n');
|
|
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
|
export 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');
|
|
export 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.
|
|
export 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);export const S=commit('row 905: S');
|
|
git(repo,'checkout','-q','-b','unreached',A);export const U=commit('row 906: U');
|
|
git(repo,'checkout','-q','refactor');git(repo,'merge','-q','--no-ff','-m','merge side','side');
|
|
export 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).
|
|
export 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 (packages/organization/src, and the packages/business/src shims the tool imports, rename r2
|
|
// stage B) and roles, which the tool reads the business file with.
|
|
export const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8');
|
|
export const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-before-c216f47db": Object.freeze({');
|
|
assert.notEqual(KINDS_OLD,KINDS_NEW);
|
|
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
|
|
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
|
|
// from before row 192, which refuses a `console` key. The other copies have no loader. Row 203: B's
|
|
// loader also refuses a `federation` key, as a pre-203 loader does (its list has no federation).
|
|
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
|
|
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console", "federation"]','["configVersion", "environment", "dataRoot", "execution"]');
|
|
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
|
|
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
|
|
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
|
|
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${[C1,C].includes(id)?"// serve accepts '--sso' (row 192)\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],...ssoFiles(id).map(([f,text])=>[f,text,0o644])]){
|
|
mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
|
|
}
|
|
for(const p of ['packages/organization/src','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
|
// Row 333 R2 (#1831): what the business loader imports from outside its directory (kinds.mjs, which is
|
|
// already here in its OLD or NEW form, and model-quota.mjs).
|
|
for(const p of organizationImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p));
|
|
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`);
|
|
export const SR=join(repo,'scripts','stack-release.sh');
|
|
|
|
// --- Per-test roots and helpers ---
|
|
|
|
export const sha=(f)=>createHash('sha256').update(readFileSync(f)).digest('hex');
|
|
export const lines=(f)=>existsSync(f)?readFileSync(f,'utf8').split('\n').filter(Boolean):[];
|
|
export const calls=(e)=>lines(join(e.sd,'calls.log'));
|
|
export const startsStops=(e,from)=>calls(e).slice(from).filter((c)=>/^(start|stop) /.test(c));
|
|
export const logLines=(e)=>lines(e.log).map((l)=>JSON.parse(l));
|
|
export const pointer=(e)=>existsSync(e.pointer)?JSON.parse(readFileSync(e.pointer,'utf8')):null;
|
|
export const units=(e)=>JSON.parse(readFileSync(join(e.sd,'units.json'),'utf8'));
|
|
export const snaps=(e)=>lines(join(e.sd,'snap.jsonl')).map((l)=>JSON.parse(l));
|
|
export const fault=(e,name,text='')=>writeFileSync(join(e.sd,'faults',name),text);
|
|
export const sysctl=(e,...a)=>execFileSync(join(SBIN,'systemctl'),['--user',...a],{env:e.env,encoding:'utf8'});
|
|
export 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))};
|
|
export const posts=(e)=>readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-'));
|
|
export const backupSum=(e,act)=>readFileSync(join(e.data,'backups','stack',act,'SHA256SUMS'),'utf8').split(' ')[0];
|
|
export 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.
|
|
export 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.
|
|
export 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.
|
|
export 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. Under --units external confirm removes a set's AWAITING-UNITS
|
|
// and FAILED when it closes the activation (row 232, r6 A5 and R4); user mode writes neither.
|
|
const markers=/\/(AWAITING-UNITS|FAILED) /;
|
|
export const kept=(e)=>[...tree(join(e.data,'backups','stack')),...tree(join(e.state,'stack-rollback')).filter((l)=>!markers.test(l)),...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.
|
|
export const EXAMPLE=readFileSync(join(repo,'packages','organization','examples','mosaic-stack.example.json'),'utf8');
|
|
export 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);
|
|
}
|
|
|
|
export async function mk(t,dataName='data'){
|
|
const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-')));
|
|
const e={root,data:join(root,dataName),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:'[email protected]',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.
|
|
export 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.
|
|
export 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;
|
|
}
|
|
|
|
export 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.
|
|
export 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'),'');
|
|
}
|
|
|
|
export const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
|
|
export const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
|
|
export const planNoBackup='backup: none; the run rolls back before the backup';
|
|
export const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: 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}`];
|