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','user.email=test@example.invalid',...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); });