Files
stack/packages/bus/tests/autonomy-host.test.mjs

71 lines
6.5 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {execFileSync} from 'node:child_process';
import {Store} from '../src/store.mjs';
import {Broker} from '../src/broker.mjs';
import {autonomyHost} from '../src/autonomy-host.mjs';
import {initializeWorking,workingRepo,push,prepareLand,finishLand,dropLand} from '../../seat/src/working.mjs';
import {gitEnv} from '../../seat/src/checkout.mjs';
const TASK='vikunja:1/2',SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=Test','-c','[email protected]',...args],{cwd,encoding:'utf8',env:gitEnv()}).trim();
async function world(t,level='near the loop') {
const dir=mkdtempSync(join(tmpdir(),'au-')),root=join(dir,'root'),dataRoot=join(dir,'data');mkdirSync(root);mkdirSync(dataRoot,{mode:0o700});
git(root,'init','-q','-b','main');writeFileSync(join(root,'file'),'before\n');git(root,'add','file');git(root,'commit','-qm','Initial');
const before=git(root,'rev-parse','HEAD');
const clone=join(dir,'clone');git(dir,'clone','-q',root,clone);git(clone,'checkout','-qb','feat');writeFileSync(join(clone,'file'),'after\n');git(clone,'commit','-qam','Change');const reviewed=git(clone,'rev-parse','HEAD');
const bundle=join(dir,'bundle');git(clone,'bundle','create',bundle,'refs/heads/feat','--not','--remotes=origin');
await initializeWorking({dataRoot,business:'demo',project:'stack',root});
await push({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,branch:'feat',bundle});
const authority=(withinRole=[],crossRole=[])=>({withinRole,crossRole});
const business={id:'demo',human:'jason',projects:{stack:{root}},autonomy:{merge:level,notices:true,reverseWindowHours:1},arbiters:{technical:'cto',delivery:'cto'},roles:{cto:{definition:'cto',authority:authority(['decision.resolve.technical'])},coder:{definition:'coder',authority:authority(['git.push.working','review.request','message.send'])},reviewer:{definition:'reviewer',authority:authority(['review.verdict','message.send'])},gate:{definition:'merge-gate',authority:authority([],['git.merge.protected'])}}};
let gates=0;
const host=autonomyHost({dataRoot,business:()=>business,processing:{work:fn=>fn({guard(){},signal:undefined})},gate:{classify:async()=>true,run:async()=>{gates++;return {ok:true};}}});
const store=new Store(dataRoot),broker=new Broker({store,businesses:{demo:business},autonomy:host}),caps={};
t.after(()=>{store.close();rmSync(dir,{recursive:true,force:true});});
const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
for(const role of Object.keys(business.roles)){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
caps.human=broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
broker.checkPush(caps.coder,TASK);broker.recordPush(caps.coder,{task_ref:TASK,project:'stack',branch:'feat',ref:'work/vikunja-1-2',commit:reviewed,previous:null});
const request=call('coder','review.request',{task_ref:TASK,commit:reviewed,to:'reviewer'});
call('reviewer','review.verdict',{task_ref:TASK,request:request.request,verdict:'approve',body:'Reviewed'});
const d=call('gate','decision.raise',{action:'git.merge.protected',project:'stack',target:TASK,question:'Land work/vikunja-1-2?',options:[{key:'yes',text:'Land change'},{key:'no',text:'Hold'}],recommendation:'yes',blocking:false});
call('cto','decision.resolve',{id:d.id,choice:'yes'});
const land=async()=>{
const facts=broker.checkLand(caps.gate,{task_ref:TASK,decision:d.id});
const logs=join(dir,'land');mkdirSync(logs,{recursive:true});
const prepared=await prepareLand({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,commit:reviewed,dir:logs,name:'original',rootHits:()=>[],message:()=> 'Change'});
try {await finishLand({dataRoot,business:'demo',project:'stack',land:prepared,record:r=>broker.recordLand(facts.ticket,{commit:r.commit,previous:r.previous,reviewed,base:prepared.base,patch_id:prepared.patch_id})});}finally{await dropLand({dataRoot,business:'demo',project:'stack',worktree:prepared.worktree});}
return prepared.commit;
};
return {broker,business,store,caps,call,d,before,land,gates:()=>gates,show:(...args)=>git(dir,`--git-dir=${workingRepo(dataRoot,'demo','stack')}`,...args)};
}
test('AU near merge lands immediately, owner notice reverses through gate and records a real child revert',async t=>{
const f=await world(t),landed=await f.land();
assert.equal(f.show('show','main:file'),'after');assert.equal(f.call('human','inbox').find(x=>x.id===f.d.id).autonomy.notice,true);
const reverse=await f.call('owner','decision.reverse.console',{id:f.d.id});
assert.equal(f.gates(),1);assert.equal(f.show('show','main:file'),'before');assert.equal(f.show('rev-parse','main^'),landed);
assert.match(f.show('log','-1','--format=%B','main'),new RegExp(f.d.id));
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
assert.equal(reverse.reverted,landed);
});
test('AU in-loop landing refuses until confirmation; decline requires a new arbiter resolution',async t=>{
const f=await world(t,'in the loop');
await assert.rejects(f.land(),e=>['not-resolved','confirmation-required'].includes(e.code));
f.call('owner','decision.confirm.console',{id:f.d.id,confirm:false,reason:'Review again'});
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
f.call('cto','decision.resolve',{id:f.d.id,choice:'yes'});f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true});
await f.land();assert.equal(f.show('show','main:file'),'after');
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
});
test('AU landing rechecks the resolver right both before land and after the gate',async t=>{
const f=await world(t);const facts=f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id});
f.business.roles.cto.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
assert.throws(()=>f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id}),{code:'not-resolver'});
assert.throws(()=>f.broker.recordLand(facts.ticket,{commit:'a'.repeat(40),previous:f.before,reviewed:'b'.repeat(40),base:f.before,patch_id:'c'.repeat(40)}),{code:'not-resolver'});
assert.equal(f.show('rev-parse','main'),f.before);
});