71 lines
6.5 KiB
JavaScript
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);
|
|
});
|