585 lines
41 KiB
JavaScript
585 lines
41 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdirSync,writeFileSync,readFileSync,readdirSync,unlinkSync,symlinkSync,renameSync,existsSync,lstatSync,chmodSync,rmSync } from 'node:fs';
|
|
import { spawn } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { once } from 'node:events';
|
|
import { join } from 'node:path';
|
|
import { initialize,check,status,readAccounting,supportedHalt,main,serveBudget,CUTOFF } from './budget-supervisor.mjs';
|
|
import { fixture,closeChild } from './test-support.mjs';
|
|
import { Store,SCHEMA_VERSION } from '../../bus/src/store.mjs';
|
|
const NOW=Date.parse('2026-11-06T02:30:00.000Z');
|
|
const row=(extra={})=>({at:'2026-11-06T02:29:00.000Z',run:'r123456789abc',turn:'t1',source:'transcript',instance:'assistant',provider:'proxy',route:'glm',model:'glm-5.3-flash',status:'known',input:1,output:1,cacheWrite:1,cacheRead:1,...extra});
|
|
function setup(t,tokens=5,changes={}) {
|
|
const f=fixture(t),store=new Store(f.dataRoot);f.after(()=>store.close());
|
|
const campaign=join(f.root,'campaign.json'),ledger=join(f.root,'ledger');
|
|
const c={version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens},{provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:6}]};
|
|
Object.assign(c,changes);
|
|
writeFileSync(campaign,JSON.stringify(c),{mode:0o600});initialize({campaign,ledger});
|
|
const halts=[],halt=b=>{halts.push(b);return {halted:true,quiescent:true,generation:'test'};};
|
|
return {...f,store,campaign,ledger,c,halts,halt,opts:{campaign,ledger}};
|
|
}
|
|
test('tiny cap counts all four token kinds per model, persists/deduplicates across checks and latches entire campaign',async t=>{
|
|
const f=setup(t);const opts={now:()=>NOW,halt:f.halt,read:()=>[row()]};
|
|
assert.equal((await check(f.opts,opts)).stopped,false);
|
|
assert.equal((await check(f.opts,opts)).totals[0].total,4);assert.equal(f.halts.length,0);
|
|
const stopped=await check(f.opts,{...opts,read:()=>[row(),row({turn:'t2',input:0,output:1,cacheRead:0,cacheWrite:0})]});
|
|
assert.equal(stopped.reason,'model-cap-reached');assert.equal(stopped.totals[0].total,5);assert.equal(stopped.totals[1].total,0);
|
|
assert.equal(f.halts[0].dataRoot,f.dataRoot);
|
|
assert.equal(status(f.opts).stopped,true);assert.equal(status(f.opts).totals[0].total,5);
|
|
assert.equal((await check(f.opts,opts)).reason,'stop-already-latched');assert.equal(f.halts.length,2);
|
|
assert.throws(()=>initialize(f.opts));
|
|
});
|
|
for(const [name,records,reason] of [
|
|
['unknown',[row({status:'unknown'})],'usage-unknown-or-malformed'],
|
|
['malformed',[row({input:-1})],'usage-unknown-or-malformed'],
|
|
['unsafe integer',[row({input:Number.MAX_SAFE_INTEGER+1})],'usage-unknown-or-malformed'],
|
|
['route drift',[row({route:'anthropic'})],'usage-uncapped-route-model'],
|
|
['unlisted model',[row({model:'another-model'})],'usage-uncapped-route-model'],
|
|
['duplicate',[row(),row()],'usage-duplicate'],
|
|
])test(`${name} latches before the test-only halt`,async t=>{
|
|
const f=setup(t);
|
|
const result=await check(f.opts,{now:()=>NOW,read:()=>records,halt:b=>{assert.ok(readFileSync(join(f.ledger,'stop.json')).length);return f.halt(b);}});
|
|
assert.equal(result.reason,reason);assert.equal(f.halts.length,1);
|
|
});
|
|
test('campaign deadline, immutable owner cap and corrupt/missing ledger all stop without auto reset',async t=>{
|
|
for(const variant of ['deadline','raise','malformed','missing']) {
|
|
const sub=await t.test(variant,async t=>{
|
|
const f=setup(t);let now=NOW;
|
|
if(variant==='deadline')now=Date.parse(f.c.end);
|
|
if(variant==='raise'){f.c.caps[0].tokens=10;writeFileSync(f.campaign,JSON.stringify(f.c));}
|
|
if(variant==='malformed')writeFileSync(join(f.ledger,readdirSync(f.ledger).find(n=>n.endsWith('.jsonl'))),'{');
|
|
if(variant==='missing')unlinkSync(join(f.ledger,readdirSync(f.ledger).find(n=>n.endsWith('.jsonl'))));
|
|
const result=await check(f.opts,{now:()=>now,read:()=>[],halt:f.halt});assert.equal(result.stopped,true);assert.equal(f.halts.length,1);
|
|
});
|
|
}
|
|
});
|
|
test('halt refusal keeps the durable stop and records an unknown halt, with no resume or raise',async t=>{
|
|
const f=setup(t,1);
|
|
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:()=>{throw new Error('human-required');}}),/supported-halt-refused/);
|
|
assert.equal(status(f.opts).stopped,true);
|
|
assert.equal(JSON.parse(readFileSync(join(f.ledger,'halt-1.json'))).ok,false);
|
|
await check(f.opts,{now:()=>NOW,halt:f.halt});assert.equal(f.halts.length,1);
|
|
});
|
|
test('usage removal or rewrite is refused rather than refilling or double-counting',async t=>{
|
|
const f=setup(t,100);await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt});
|
|
const result=await check(f.opts,{now:()=>NOW,read:()=>[row({input:0})],halt:f.halt});assert.equal(result.reason,'usage-rewritten');
|
|
const g=setup(t,100);await check(g.opts,{now:()=>NOW,read:()=>[row()],halt:g.halt});
|
|
assert.equal((await check(g.opts,{now:()=>NOW,read:()=>[],halt:g.halt})).reason,'usage-disappeared');
|
|
});
|
|
function insert(f,phase='end',status='known',source='transcript') {
|
|
f.store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
row().at,f.c.business,row().run,'assistant','proxy','glm-5.3-flash','t1',source,phase,status,...(status==='known'?[1,1,1,1]:[null,null,null,null]));
|
|
const manifest=join(f.dataRoot,'launches',f.c.business,row().run,'bundle');mkdirSync(manifest,{recursive:true,mode:0o700});
|
|
writeFileSync(join(manifest,'manifest.json'),JSON.stringify({business:f.c.business,run:row().run,instance:'assistant',provider:'proxy',model:'glm-5.3-flash',proxyRoute:'glm',route:'primary'}),{mode:0o600});
|
|
}
|
|
test('accounting reads a real store at the current bus schema version',t=>{
|
|
const f=setup(t,100);insert(f);
|
|
assert.equal(f.store.get("SELECT value FROM meta WHERE key='schema_version'").value,SCHEMA_VERSION);
|
|
const records=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});
|
|
assert.equal(records.length,1);assert.equal(records[0].input,1);
|
|
});
|
|
for(const version of ['3s','3r','3u',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
|
|
const f=setup(t,100);
|
|
// Only this disposable fixture is altered; the production meta table is append-only.
|
|
f.store.run('DROP TRIGGER meta_no_update');f.store.run('DROP TRIGGER meta_no_delete');
|
|
if(version===null) f.store.run("DELETE FROM meta WHERE key='schema_version'");
|
|
else f.store.run("UPDATE meta SET value=? WHERE key='schema_version'",version);
|
|
const read=()=>readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});
|
|
if(version==='3s') assert.deepEqual(read(),[]);
|
|
else assert.throws(read,/^Error: test-store-schema-refused$/);
|
|
});
|
|
test('actual read-only bus store source matches token-limit records and excludes proxy telemetry',async t=>{
|
|
const f=setup(t,100);insert(f);insert(f,'end','known','@proxy/cliproxy-queue');
|
|
const r=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});assert.equal(r.length,1);assert.equal(r[0].route,'glm');
|
|
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).totals[0].total,4);
|
|
assert.equal(f.store.get('SELECT count(*) n FROM usage_records').n,2);
|
|
});
|
|
test('a completed message with no usage records stops; an in-flight begin waits, an ended unknown turn stops',async t=>{
|
|
const f=setup(t,100);insert(f,'begin');
|
|
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,false);
|
|
f.store.run(`INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,?,?,?,?)`,'ended',row().at,f.c.business,'session.ended',row().run,'{}');
|
|
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).reason,'usage-unknown');
|
|
const g=setup(t,100);
|
|
g.store.run(`INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)`,'t1',row().at,g.c.business,'chief','chief-run','assistant','note','work');
|
|
g.store.run(`INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,?,?)`,'t1',row().at,'read',row().run);
|
|
assert.equal((await check(g.opts,{now:()=>NOW,halt:g.halt})).reason,'usage-missing');
|
|
});
|
|
test('completed unknown source and fallback manifest fail closed against the real store',async t=>{
|
|
const f=setup(t,100);insert(f,'end','unknown');
|
|
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).reason,'usage-unknown');
|
|
const g=setup(t,100);insert(g);
|
|
const path=join(g.dataRoot,'launches',g.c.business,row().run,'bundle/manifest.json'),m=JSON.parse(readFileSync(path));m.route='fallback';writeFileSync(path,JSON.stringify(m));
|
|
assert.equal((await check(g.opts,{now:()=>NOW,halt:g.halt})).reason,'usage-route-drift');
|
|
});
|
|
test('changed target config never directs a halt to a different root',async t=>{
|
|
const f=setup(t,1),other=join(f.root,'other');mkdirSync(other);writeFileSync(join(other,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'}));
|
|
const config=JSON.parse(readFileSync(f.config));config.dataRoot=other;writeFileSync(f.config,JSON.stringify(config));
|
|
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}));assert.equal(f.halts.length,0);
|
|
});
|
|
test('normal supported halt refuses an agent environment, preserving its markers',t=>{
|
|
const f=setup(t);assert.throws(()=>supportedHalt({config:f.config,dataRoot:f.dataRoot,business:f.c.business},{...process.env,MOSAIC_AGENT_RUN:'test-agent'}),/supported-halt-refused/);
|
|
});
|
|
test('a corrupt ledger manifest stops with the validated owner campaign, never recreates accounting',async t=>{
|
|
const f=setup(t,100);writeFileSync(join(f.ledger,'campaign.json'),'{');
|
|
const result=await check(f.opts,{now:()=>NOW,read:()=>[],halt:f.halt});assert.equal(result.reason,'ledger-manifest-invalid');assert.equal(f.halts.length,1);
|
|
assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),'{');
|
|
});
|
|
test('an invalid existing stop latch still invokes the supported test halt',async t=>{
|
|
const f=setup(t,100);mkdirSync(join(f.ledger,'stop.json'),{mode:0o700});
|
|
const result=await check(f.opts,{now:()=>NOW,read:()=>[],halt:f.halt});assert.equal(result.reason,'stop-latch-invalid');assert.equal(f.halts.length,1);
|
|
});
|
|
test('model-writable campaign paths refuse initialization',t=>{
|
|
const f=fixture(t),store=new Store(f.dataRoot);f.after(()=>store.close());
|
|
const campaign=join(f.dataRoot,'campaign.json');writeFileSync(campaign,JSON.stringify({version:1,instance:'ui797',config:f.config,business:'mvp3-ui797',owner:'ui797-owner',start:'2026-11-06T02:00:00.000Z',end:'2026-11-06T03:00:00.000Z',caps:[{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:1}]}),{mode:0o600});
|
|
assert.throws(()=>initialize({campaign,ledger:join(f.root,'ledger')}),/outside/);
|
|
});
|
|
|
|
test('a failed durable-latch write still halts and returns an explicit storage failure',async t=>{
|
|
const f=setup(t,1);symlinkSync(join(f.root,'missing-stop-target'),join(f.ledger,'stop.json'));
|
|
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/stop-latch-not-durable/);
|
|
assert.equal(f.halts.length,1);assert.equal(JSON.parse(readFileSync(join(f.ledger,'halt-1.json'))).storageError,'stop-latch-not-durable');
|
|
});
|
|
|
|
test('valid JSON journal token corruption durably latches and halts before accepting another turn',async t=>{
|
|
const f=setup(t),deps={now:()=>NOW,read:()=>[row()],halt:f.halt};
|
|
await check(f.opts,deps);
|
|
const file=readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>join(f.ledger,n)).find(n=>readFileSync(n,'utf8').includes('"key"'));
|
|
const lines=readFileSync(file,'utf8').trimEnd().split('\n').map(JSON.parse);lines[1].tokens=0;
|
|
writeFileSync(file,lines.map(JSON.stringify).join('\n')+'\n');
|
|
const result=await check(f.opts,{...deps,read:()=>[row(),row({turn:'t2'})]});
|
|
assert.equal(result.reason,'ledger-token-mismatch');assert.equal(f.halts.length,1);
|
|
assert.equal(JSON.parse(readFileSync(join(f.ledger,'stop.json'))).reason,'ledger-token-mismatch');
|
|
});
|
|
|
|
test('a check killed while holding its lock is halted on restart without reclaiming the lock',async t=>{
|
|
const f=setup(t,100);
|
|
const code=`import {check} from ${JSON.stringify(new URL('./budget-supervisor.mjs',import.meta.url).href)}; await check(${JSON.stringify(f.opts)},{now:()=>${NOW},read:()=>{process.send('locked');return new Promise(()=>{});}});`;
|
|
const child=spawn(process.execPath,['--input-type=module','-e',code],{stdio:['ignore','ignore','pipe','ipc']});
|
|
f.after(async()=>{if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await once(child,'close');}});
|
|
await once(child,'message');child.kill('SIGKILL');await once(child,'close');
|
|
const lock=join(f.ledger,'check.lock'),ino=lstatSync(lock).ino;
|
|
const result=await check(f.opts,{now:()=>NOW,read:()=>assert.fail('locked accounting must not run'),halt:f.halt});
|
|
assert.equal(result.reason,'check-lock-unavailable');assert.equal(f.halts.length,1);
|
|
assert.equal(lstatSync(lock).ino,ino);assert.ok(existsSync(join(f.ledger,'stop.json')));
|
|
});
|
|
|
|
test('an existing latch reasserts halt on every locked restart and preserves lock evidence',async t=>{
|
|
const f=setup(t,1);await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt});
|
|
const lock=join(f.ledger,'check.lock');mkdirSync(lock,{mode:0o700});writeFileSync(join(lock,'owner'),'diagnose');
|
|
for(let n=0;n<2;n++) assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,true);
|
|
assert.equal(f.halts.length,3);assert.equal(readFileSync(join(lock,'owner'),'utf8'),'diagnose');
|
|
});
|
|
|
|
test('lock storage failures still durably stop the verified test workload',async t=>{
|
|
for(const code of ['EROFS','EACCES']) {
|
|
const f=setup(t,100);
|
|
const result=await check(f.opts,{now:()=>NOW,halt:b=>{assert.equal(b.dataRoot,f.dataRoot);assert.ok(existsSync(join(f.ledger,'stop.json')));return f.halt(b);},acquireLock:()=>{throw Object.assign(new Error('storage'),{code});}});
|
|
assert.equal(result.reason,'check-lock-unavailable');assert.equal(f.halts.length,1);
|
|
}
|
|
const f=setup(t,100),lock=join(f.ledger,'check.lock');writeFileSync(lock,'not a directory',{mode:0o600});
|
|
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).stopped,true);
|
|
assert.equal(readFileSync(lock,'utf8'),'not a directory');assert.equal(f.halts.length,1);
|
|
});
|
|
|
|
test('budget init, check, status and serve refuse unrelated instance metadata before accounting or halt',async t=>{
|
|
const f=setup(t,100),other=join(f.root,'mosaic-unrelated');renameSync(join(f.root,'mosaic-ui797'),other);
|
|
const config=join(other,'config.json');writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'}));
|
|
f.c.instance='unrelated';f.c.config=config;writeFileSync(f.campaign,JSON.stringify(f.c));
|
|
assert.throws(()=>initialize({campaign:f.campaign,ledger:join(f.root,'other-ledger')}),/campaign-invalid/);
|
|
const file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));p.campaign=f.c;writeFileSync(file,JSON.stringify(p));
|
|
await assert.rejects(check(f.opts,{now:()=>NOW,halt:f.halt}),/literal ui797/);
|
|
assert.throws(()=>status(f.opts),/campaign-invalid/);
|
|
await assert.rejects(main(['serve','--campaign',f.campaign,'--ledger',f.ledger]),/literal ui797/);
|
|
assert.equal(f.halts.length,0);
|
|
});
|
|
|
|
test('persisted campaign metadata is compared to the validated owner manifest before accounting',async t=>{
|
|
const f=setup(t,100),file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));
|
|
p.campaign.caps[0].tokens=101;writeFileSync(file,JSON.stringify(p));
|
|
assert.equal((await check(f.opts,{now:()=>NOW,read:()=>assert.fail('changed metadata'),halt:f.halt})).reason,'campaign-changed');
|
|
assert.equal(f.halts.length,1);
|
|
});
|
|
|
|
test('absolute cutoff is inclusive; later windows refuse and a persisted extension cannot delay a stop',async t=>{
|
|
const end='2026-11-07T06:00:00.000Z',f=setup(t,100,{end});
|
|
assert.equal((await check(f.opts,{now:()=>CUTOFF-1,read:()=>[],halt:f.halt})).stopped,false);
|
|
assert.equal((await check(f.opts,{now:()=>CUTOFF,read:()=>[],halt:f.halt})).reason,'campaign-absolute-cutoff');
|
|
assert.equal(f.halts.length,1);
|
|
assert.throws(()=>setup(t,100,{end:'2026-11-08T06:00:00.000Z'}),/campaign-invalid/);
|
|
const g=setup(t,100,{end}),file=join(g.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));
|
|
p.campaign.end='2026-11-08T06:00:00.000Z';writeFileSync(file,JSON.stringify(p));
|
|
assert.equal((await check(g.opts,{now:()=>CUTOFF,halt:g.halt})).reason,'campaign-absolute-cutoff');
|
|
assert.equal(g.halts.length,1);
|
|
});
|
|
|
|
test('only the exact proxy/glm identity receives 100M; other approved models remain capped at 10M',t=>{
|
|
const cap=(provider,route,model,tokens)=>({provider,route,model,tokens});
|
|
const glm=cap('proxy','glm','glm-5.3-flash',100000000),claude=cap('proxy','anthropic','claude-opus-5-5',10000000),codex=cap('proxy','codex','gpt-6-astra',10000000);
|
|
for(const c of [glm,claude,codex])assert.ok(setup(t,1,{caps:[c]}));
|
|
for(const c of [glm,claude,codex])assert.throws(()=>setup(t,1,{caps:[{...c,tokens:c.tokens+1}]}),/campaign-cap-invalid/);
|
|
for(const c of [cap('zai','glm','glm-5.3-flash',100000000),cap('proxy','anthropic','glm-5.3-flash',100000000),cap('proxy','glm','claude-opus-5-5',100000000)])assert.throws(()=>setup(t,1,{caps:[c]}),/campaign-cap-invalid/);
|
|
});
|
|
|
|
test('conflicting route bindings refuse initialization and real-store split-route usage stops',async t=>{
|
|
const cap={provider:'proxy',route:'anthropic',model:'claude-opus-5-5',tokens:10000000};
|
|
assert.throws(()=>setup(t,1,{caps:[{...cap,route:'glm'},cap]}),/campaign-cap-invalid/);
|
|
assert.throws(()=>setup(t,1,{caps:[cap,{...cap}]}),/campaign-cap-invalid/);
|
|
const f=setup(t,1,{caps:[cap]});
|
|
for(const [i,route] of ['anthropic','glm'].entries()) {
|
|
const r=row({run:i?'r123456789abd':'r123456789abc',turn:`t${i}`,model:cap.model,input:6000000,output:0,cacheWrite:0,cacheRead:0});
|
|
f.store.run('INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',r.at,f.c.business,r.run,r.instance,r.provider,r.model,r.turn,r.source,'end','known',r.input,0,0,0);
|
|
const dir=join(f.dataRoot,'launches',f.c.business,r.run,'bundle');mkdirSync(dir,{recursive:true,mode:0o700});
|
|
writeFileSync(join(dir,'manifest.json'),JSON.stringify({business:f.c.business,run:r.run,instance:r.instance,provider:r.provider,model:r.model,proxyRoute:route,route:'primary'}),{mode:0o600});
|
|
}
|
|
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
|
|
assert.equal(result.reason,'usage-uncapped-route-model');assert.equal(f.halts.length,1);
|
|
assert.ok(existsSync(join(f.ledger,'stop.json')));
|
|
});
|
|
|
|
test('the authorized GLM and other-model maxima halt at the exact charged ceiling',async t=>{
|
|
for(const cap of [{provider:'proxy',route:'glm',model:'glm-5.3-flash',tokens:100000000},{provider:'proxy',route:'anthropic',model:'claude-sonnet-5-5',tokens:10000000}]) {
|
|
const f=setup(t,1,{caps:[cap]});
|
|
const r=row({...cap,input:cap.tokens,output:0,cacheWrite:0,cacheRead:0});delete r.tokens;
|
|
const result=await check(f.opts,{now:()=>NOW,read:()=>[r],halt:f.halt});
|
|
assert.equal(result.reason,'model-cap-reached');assert.equal(result.totals[0].total,cap.tokens);assert.equal(f.halts.length,1);
|
|
}
|
|
});
|
|
|
|
for(const variant of ['renamed','missing','mode-0500','cutoff','latched-restart'])test(`unavailable ledger (${variant}) still counts a halt without recreating accounting`,async t=>{
|
|
const f=setup(t,1),offline=f.ledger+'-offline',deps={now:()=>NOW,halt:f.halt,read:()=>[row()]};
|
|
let priorLatch;
|
|
if(variant==='latched-restart') {
|
|
await check(f.opts,deps);priorLatch=readFileSync(join(f.ledger,'stop.json'),'utf8');
|
|
f.halts.length=0; // Model Resume and a fresh check, without any cached binding.
|
|
}
|
|
const before=readdirSync(f.ledger).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]);
|
|
if(variant==='mode-0500'){chmodSync(f.ledger,0o500);f.after(()=>chmodSync(f.ledger,0o700));}
|
|
else if(variant==='missing')rmSync(f.ledger,{recursive:true});
|
|
else renameSync(f.ledger,offline);
|
|
let running=true;
|
|
const attempts=variant==='latched-restart'?2:1;
|
|
for(let n=0;n<attempts;n++) {
|
|
await assert.rejects(check(f.opts,{...deps,now:()=>variant==='cutoff'?CUTOFF:NOW,halt:b=>{
|
|
assert.equal(running,true);running=false;return f.halt(b);
|
|
}}),e=>{
|
|
assert.deepEqual(e.result.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
|
|
assert.equal(e.result.halt.halted,true);return true;
|
|
});
|
|
assert.equal(running,false);running=true; // Model another Resume after restarted halt.
|
|
}
|
|
assert.equal(f.halts.length,attempts);assert.equal(f.halts[0].dataRoot,f.dataRoot);
|
|
if(variant==='mode-0500')assert.deepEqual(readdirSync(f.ledger).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]),before);
|
|
else assert.equal(existsSync(f.ledger),false);
|
|
if(priorLatch)assert.equal(readFileSync(join(offline,'stop.json'),'utf8'),priorLatch);
|
|
});
|
|
|
|
test('an unavailable ledger refuses invalid, foreign, changed or unpinned targets before halt',async t=>{
|
|
for(const variant of ['invalid','foreign','changed-root','changed-config','changed-campaign','missing-pin']) {
|
|
const f=setup(t,1);renameSync(f.ledger,f.ledger+'-offline');
|
|
const c=JSON.parse(readFileSync(f.config));
|
|
if(variant==='invalid')writeFileSync(f.config,'{');
|
|
if(variant==='foreign')writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'unrelated'}));
|
|
if(variant==='changed-root') {
|
|
const other=join(f.root,'other');mkdirSync(other);writeFileSync(join(other,'instance.json'),JSON.stringify({instanceVersion:1,name:'ui797'}));
|
|
const store=new Store(other);f.after(()=>store.close());c.dataRoot=other;writeFileSync(f.config,JSON.stringify(c));
|
|
}
|
|
if(variant==='changed-config'){c.execution.model='other';writeFileSync(f.config,JSON.stringify(c));}
|
|
if(variant==='changed-campaign'){f.c.business='other-business';writeFileSync(f.campaign,JSON.stringify(f.c));}
|
|
if(variant==='missing-pin')unlinkSync(f.campaign+'.binding.json');
|
|
await assert.rejects(check(f.opts,{now:()=>NOW,halt:f.halt}));assert.equal(f.halts.length,0,variant);
|
|
assert.equal(existsSync(f.ledger),false);
|
|
}
|
|
});
|
|
|
|
test('a required modern sidecar cannot be deleted to regain an allowance; unpinned legacy evidence refuses',async t=>{
|
|
const f=setup(t,100),manifest=readFileSync(join(f.ledger,'campaign.json'),'utf8');
|
|
unlinkSync(f.campaign+'.binding.json');
|
|
const result=await check(f.opts,{now:()=>NOW,read:()=>assert.fail('missing required pin'),halt:f.halt});
|
|
assert.equal(result.stopped,true);assert.deepEqual(result.evidenceErrors,['owner-binding-missing']);
|
|
assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),manifest);
|
|
assert.equal(existsSync(f.campaign+'.binding.json'),false);assert.equal(f.halts.length,1);
|
|
const g=setup(t,100),file=join(g.ledger,'campaign.json'),old=JSON.parse(readFileSync(file));
|
|
old.version=1;delete old.configHash;writeFileSync(file,JSON.stringify(old));unlinkSync(g.campaign+'.binding.json');
|
|
await assert.rejects(check(g.opts,{now:()=>NOW,halt:g.halt}),/owner-binding-unverified/);
|
|
assert.equal(g.halts.length,0);
|
|
const h=setup(t,100),hfile=join(h.ledger,'campaign.json'),previous=JSON.parse(readFileSync(hfile));
|
|
previous.version=1;delete previous.configHash;writeFileSync(hfile,JSON.stringify(previous));
|
|
const before=readFileSync(hfile,'utf8');
|
|
assert.equal((await check(h.opts,{now:()=>NOW,halt:h.halt,read:()=>[]})).stopped,false);
|
|
assert.equal(readFileSync(hfile,'utf8'),before);
|
|
});
|
|
|
|
test('serve survives unavailable storage, reasserts after Resume each tick and preserves its in-memory stop on recovery',async t=>{
|
|
const f=setup(t,100),offline=f.ledger+'-offline',controller=new AbortController(),reports=[];
|
|
await check(f.opts,{now:()=>NOW,halt:f.halt,read:()=>[]});
|
|
const epoch=readFileSync(join(f.ledger,'campaign.json'),'utf8');renameSync(f.ledger,offline);
|
|
let running=true;
|
|
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[],halt:b=>{
|
|
assert.equal(running,true);running=false;return f.halt(b);
|
|
},report:r=>{
|
|
reports.push(r);assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);
|
|
running=true; // Model Resume between every tick.
|
|
if(reports.length===2)renameSync(offline,f.ledger);
|
|
if(reports.length===3)controller.abort();
|
|
}});
|
|
assert.equal(f.halts.length,3);assert.equal(reports.length,3);
|
|
for(const r of reports.slice(0,2))assert.deepEqual(r.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
|
|
assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),epoch); // Never initialize a new epoch.
|
|
assert.equal(JSON.parse(readFileSync(join(f.ledger,'stop.json'))).reason,'ledger-storage-unavailable');
|
|
});
|
|
|
|
test('serve keeps retrying a refused halt and refuses targeting if the config changes between ticks',async t=>{
|
|
const f=setup(t,1),controller=new AbortController(),reports=[];
|
|
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[row()],halt:b=>{
|
|
f.halts.push(b);throw new Error('modelled halt refusal');
|
|
},report:r=>{
|
|
reports.push(r);
|
|
if(reports.length===2){const c=JSON.parse(readFileSync(f.config));c.execution.model='changed';writeFileSync(f.config,JSON.stringify(c));}
|
|
if(reports.length===4)controller.abort();
|
|
}});
|
|
assert.equal(f.halts.length,2);assert.equal(reports.length,4);
|
|
for(const r of reports.slice(0,2))assert.match(r.error,/supported-halt-refused/);
|
|
for(const r of reports.slice(2))assert.match(r.error,/campaign-instance-changed/);
|
|
assert.equal(existsSync(join(f.ledger,'stop.json')),true);
|
|
});
|
|
|
|
for(const damage of ['missing-ledger','read','parse','mode'])test(`real serve CLI stays alive and attempts supported halt every tick with ${damage} damage`,async t=>{
|
|
const f=setup(t,1);
|
|
if(damage==='missing-ledger')renameSync(f.ledger,f.ledger+'-offline');else damagePin(f,damage);
|
|
const child=spawn(process.execPath,['packages/cli/live/budget-supervisor.mjs','serve','--campaign',f.campaign,'--ledger',f.ledger,'--interval','0.1'],{
|
|
env:{...process.env,MOSAIC_AGENT_RUN:'test-unavailable-ledger'},stdio:['ignore','pipe','pipe'],
|
|
});
|
|
f.after(()=>closeChild(child));
|
|
let text='',errors='';
|
|
child.stderr.on('data',chunk=>errors+=chunk);
|
|
const reports=[];
|
|
child.stdout.on('data',chunk=>{
|
|
text+=chunk;let end;
|
|
while((end=text.indexOf('\n'))!==-1){reports.push(JSON.parse(text.slice(0,end)));text=text.slice(end+1);}
|
|
if(reports.length>=3)child.kill('SIGTERM');
|
|
});
|
|
const timer=setTimeout(()=>child.kill('SIGKILL'),20000);
|
|
const [code,signal]=await once(child,'close');clearTimeout(timer);
|
|
assert.equal(signal,null,errors);assert.equal(code,0,errors);assert.ok(reports.length>=3,errors);
|
|
for(const r of reports) {
|
|
assert.equal(r.stopped,true);
|
|
if(damage==='missing-ledger'){
|
|
assert.equal(r.haltError,'supported-halt-refused-or-unknown');
|
|
assert.deepEqual(r.storageErrors,['stop-latch-not-durable','stop-receipt-not-durable']);
|
|
}else{assert.match(r.error,/supported-halt-refused/);assert.deepEqual(r.evidenceErrors,['owner-binding-unreadable-or-invalid']);}
|
|
}
|
|
assert.equal(existsSync(f.ledger),damage!=='missing-ledger');
|
|
});
|
|
|
|
// Every damaged-sidecar case uses the real usage store and preserves the evidence.
|
|
function damagePin(f,kind) {
|
|
const pin=f.campaign+'.binding.json';
|
|
if(kind==='read') chmodSync(pin,0o000);
|
|
if(kind==='parse') writeFileSync(pin,'{');
|
|
if(kind==='mode') chmodSync(pin,0o400);
|
|
return ()=>{
|
|
if(kind==='read'){assert.equal(lstatSync(pin).mode&0o777,0);chmodSync(pin,0o600);}
|
|
if(kind==='mode')assert.equal(lstatSync(pin).mode&0o777,0o400);
|
|
assert.equal(readFileSync(pin,'utf8'),kind==='parse'?'{':readFileSync(pin+'-original','utf8'));
|
|
};
|
|
}
|
|
for(const kind of ['read','parse','mode']) for(const state of ['restart','cutoff','over-cap','latched-resume'])
|
|
test(`sidecar ${kind} damage at ${state} still latches and halts on restart and between service ticks`,async t=>{
|
|
for(const running of [false,true]) await t.test(running?'running service':'fresh service',async t=>{
|
|
const f=setup(t,state==='over-cap'||state==='latched-resume'?3:100);
|
|
insert(f);
|
|
const pin=f.campaign+'.binding.json',epoch=readFileSync(join(f.ledger,'campaign.json'),'utf8');
|
|
writeFileSync(pin+'-original',readFileSync(pin),{mode:0o600});
|
|
const now=()=>state==='cutoff'?CUTOFF:NOW;
|
|
if(state==='latched-resume')await check(f.opts,{now,halt:f.halt});
|
|
let assertDamage;
|
|
if(!running)assertDamage=damagePin(f,kind);
|
|
const controller=new AbortController(),reports=[];
|
|
await serveBudget(f.opts,{interval:1,signal:controller.signal,now,halt:f.halt,report:r=>{
|
|
reports.push(r);
|
|
if(running && reports.length===1){assertDamage=damagePin(f,kind);f.halts.length=0;}
|
|
else {assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);assert.ok(r.evidenceErrors.includes('owner-binding-unreadable-or-invalid'));}
|
|
if(reports.length===3)controller.abort();
|
|
}});
|
|
assert.equal(f.halts.length,running?2:state==='latched-resume'?4:3);
|
|
assertDamage();assert.equal(readFileSync(join(f.ledger,'campaign.json'),'utf8'),epoch);
|
|
assert.ok(existsSync(join(f.ledger,'stop.json')));
|
|
assert.ok(JSON.parse(readFileSync(join(f.ledger,`halt-${readdirSync(f.ledger).filter(n=>/^halt-[0-9]+\.json$/.test(n)).length}.json`))).evidenceErrors.length);
|
|
});
|
|
});
|
|
|
|
test('running service retains validated target evidence when both sidecar and ledger become unavailable',async t=>{
|
|
const f=setup(t,100),controller=new AbortController(),reports=[];
|
|
await serveBudget(f.opts,{now:()=>NOW,interval:1,signal:controller.signal,read:()=>[],halt:f.halt,report:r=>{
|
|
reports.push(r);
|
|
if(reports.length===1){writeFileSync(f.campaign+'.binding.json','{');renameSync(f.ledger,f.ledger+'-offline');}
|
|
else {assert.equal(r.stopped,true);assert.equal(r.halt.halted,true);assert.equal(r.storageErrors.length,2);assert.ok(r.evidenceErrors.length);}
|
|
if(reports.length===3)controller.abort();
|
|
}});
|
|
assert.equal(f.halts.length,2);assert.equal(existsSync(f.ledger),false);
|
|
});
|
|
|
|
for(const kind of ['read','parse','mode'])test(`damaged sidecar (${kind}) never permits changed config bytes or foreign marker/root/store`,async t=>{
|
|
for(const changed of ['config','invalid','marker','root','store']) {
|
|
const f=setup(t,1);damagePin(f,kind);
|
|
if(changed==='config')writeFileSync(f.config,readFileSync(f.config,'utf8')+' ');
|
|
if(changed==='invalid')writeFileSync(f.config,'{');
|
|
if(changed==='marker')writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'foreign'}));
|
|
if(changed==='root')renameSync(f.dataRoot,f.dataRoot+'-offline');
|
|
if(changed==='store')renameSync(join(f.dataRoot,'bus/bus.sqlite'),join(f.dataRoot,'bus/bus.sqlite-offline'));
|
|
await assert.rejects(check(f.opts,{now:()=>CUTOFF,halt:f.halt}));assert.equal(f.halts.length,0,changed);
|
|
}
|
|
});
|
|
|
|
for(const variant of ['coherent-epoch-edit','zero-pin-hash'])test(`real-store ${variant} stops without accepting a fresh accounting epoch`,async t=>{
|
|
const f=setup(t,5);insert(f);
|
|
assert.equal((await check(f.opts,{now:()=>NOW,halt:f.halt})).totals[0].total,4);
|
|
const pin=f.campaign+'.binding.json',original=readFileSync(pin,'utf8');
|
|
if(variant==='coherent-epoch-edit') {
|
|
f.c.start='2026-11-06T02:29:30.000Z';const text=JSON.stringify(f.c);writeFileSync(f.campaign,text);
|
|
const file=join(f.ledger,'campaign.json'),p=JSON.parse(readFileSync(file));p.campaign=f.c;p.hash=createHash('sha256').update(text).digest('hex');writeFileSync(file,JSON.stringify(p));
|
|
for(const n of readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')))writeFileSync(join(f.ledger,n),readFileSync(join(f.ledger,n),'utf8').split('\n')[0]+'\n');
|
|
f.store.run('INSERT INTO usage_records(at,business,run,instance,provider,model,turn,source,phase,status,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
|
'2026-11-06T02:29:45.000Z',f.c.business,row().run,'assistant','proxy','glm-5.3-flash','t2','transcript','end','known',4,0,0,0);
|
|
assert.equal(f.store.get('SELECT sum(input+output+cache_write+cache_read) total FROM usage_records').total,8);
|
|
} else {const p=JSON.parse(original);p.hash='0'.repeat(64);writeFileSync(pin,JSON.stringify(p));}
|
|
const before=readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]);
|
|
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
|
|
assert.equal(result.reason,'owner-binding-epoch-changed');assert.equal(f.halts.length,1);
|
|
assert.deepEqual(readdirSync(f.ledger).filter(n=>n.endsWith('.jsonl')).map(n=>[n,readFileSync(join(f.ledger,n),'utf8')]),before);
|
|
if(variant==='coherent-epoch-edit')assert.equal(readFileSync(pin,'utf8'),original);
|
|
assert.throws(()=>status(f.opts),/owner-binding-epoch-changed/);
|
|
});
|
|
|
|
test('semantic sidecar target damage stops using independent config/marker/root/store evidence',async t=>{
|
|
for(const field of ['ledger','binding','configHash']) {
|
|
const f=setup(t,100),path=f.campaign+'.binding.json',pin=JSON.parse(readFileSync(path));
|
|
if(field==='ledger')pin.ledger+='-changed';
|
|
if(field==='binding')pin.binding.business='changed-business';
|
|
if(field==='configHash')pin.configHash='0'.repeat(64);
|
|
const damaged=JSON.stringify(pin);writeFileSync(path,damaged);
|
|
const result=await check(f.opts,{now:()=>NOW,halt:f.halt});
|
|
assert.equal(result.stopped,true);assert.ok(result.evidenceErrors.includes('owner-binding-target-changed'));
|
|
assert.equal(f.halts.length,1);assert.equal(f.halts[0].business,f.c.business);assert.equal(readFileSync(path,'utf8'),damaged);
|
|
}
|
|
});
|
|
|
|
// A conflicting ledger never authorizes its own target (support r4 P1).
|
|
function conflictingLedger(t,kind) {
|
|
const f=setup(t,100),path=join(f.ledger,'campaign.json');
|
|
const g=kind==='foreign'?setup(t,100,{business:'unrelated-business'}):null;
|
|
const alter=()=>{
|
|
if(g) { writeFileSync(path,readFileSync(join(g.ledger,'campaign.json'))); return; }
|
|
const p=JSON.parse(readFileSync(path));p.binding.business='unrelated-business';p.campaign.business='unrelated-business';writeFileSync(path,JSON.stringify(p));
|
|
};
|
|
const wrong=()=>f.halts.filter(b=>b.business!==f.c.business||b.config!==f.config||b.dataRoot!==f.dataRoot);
|
|
return {f,g,alter,wrong};
|
|
}
|
|
test('a business-only or foreign ledger is replaced by the owner-verified target in check',async t=>{
|
|
for(const kind of ['business-only','foreign']) {
|
|
const {f,alter,wrong}=conflictingLedger(t,kind);alter();
|
|
const result=await check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt});
|
|
assert.equal(result.stopped,true);assert.ok(result.evidenceErrors.includes('ledger-target-changed'));
|
|
assert.equal(f.halts.length,1);assert.deepEqual(wrong(),[]);
|
|
}
|
|
});
|
|
test('serve startup never retains a conflicting ledger target',async t=>{
|
|
for(const kind of ['business-only','foreign']) {
|
|
const {f,alter,wrong}=conflictingLedger(t,kind),controller=new AbortController(),reports=[];alter();
|
|
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[row()],halt:f.halt,report:r=>{reports.push(r);if(reports.length===3)controller.abort();}});
|
|
assert.equal(reports.length,3);assert.ok(f.halts.length>=1);assert.deepEqual(wrong(),[]);
|
|
for(const r of reports) assert.ok(r.evidenceErrors.includes('ledger-target-changed'));
|
|
}
|
|
});
|
|
test('a running service halts only its retained owner target when the ledger changes under it',async t=>{
|
|
for(const kind of ['business-only','foreign']) {
|
|
const {f,alter,wrong}=conflictingLedger(t,kind),controller=new AbortController(),reports=[];
|
|
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[row()],halt:f.halt,report:r=>{
|
|
reports.push(r);if(reports.length===1)alter();if(reports.length===3)controller.abort();
|
|
}});
|
|
assert.equal(reports[0].stopped,false);
|
|
for(const r of reports.slice(1)) { assert.equal(r.stopped,true);assert.ok(r.evidenceErrors.includes('ledger-target-changed')); }
|
|
assert.ok(f.halts.length>=1);assert.deepEqual(wrong(),[]);
|
|
}
|
|
});
|
|
test('a conflicting ledger without corroborating owner evidence refuses every halt',async t=>{
|
|
for(const kind of ['business-only','foreign']) {
|
|
const {f,alter}=conflictingLedger(t,kind);alter();
|
|
const pin=f.campaign+'.binding.json',damaged=JSON.parse(readFileSync(pin));damaged.configHash='0'.repeat(64);writeFileSync(pin,JSON.stringify(damaged));
|
|
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/campaign-instance-changed/);
|
|
assert.equal(f.halts.length,0);
|
|
}
|
|
});
|
|
|
|
// A pin damaged to match a damaged ledger, or a foreign pin restored with the
|
|
// invoked ledger path, never corroborates that ledger's target (support r5 P1).
|
|
function matchingDamage(t,kind) {
|
|
const {f,g,alter:alterLedger,wrong}=conflictingLedger(t,kind==='business'?'business-only':'foreign');
|
|
const pinPath=f.campaign+'.binding.json',owner=readFileSync(f.campaign,'utf8');
|
|
const alter=()=>{
|
|
if(kind==='business') { const pin=JSON.parse(readFileSync(pinPath));pin.binding.business='unrelated-business';writeFileSync(pinPath,JSON.stringify(pin)); }
|
|
else { const pin=JSON.parse(readFileSync(g.campaign+'.binding.json'));pin.ledger=f.ledger;writeFileSync(pinPath,JSON.stringify(pin)); }
|
|
alterLedger();
|
|
};
|
|
const receipts=dir=>readdirSync(dir).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n));
|
|
return {f,g,alter,wrong,owner,receipts};
|
|
}
|
|
test('matching pin and ledger damage refuses check and serve startup with no halt',async t=>{
|
|
for(const kind of ['business','foreign']) {
|
|
let m=matchingDamage(t,kind);m.alter();
|
|
await assert.rejects(check(m.f.opts,{now:()=>NOW,read:()=>[row()],halt:m.f.halt}),/campaign-instance-changed/);
|
|
assert.equal(m.f.halts.length,0);assert.deepEqual(m.receipts(m.f.ledger),[]);assert.equal(readFileSync(m.f.campaign,'utf8'),m.owner);
|
|
m=matchingDamage(t,kind);m.alter();
|
|
await assert.rejects(serveBudget(m.f.opts,{interval:1,now:()=>NOW,read:()=>[row()],halt:m.f.halt,report:()=>assert.fail('no tick may run')}),/campaign-instance-changed/);
|
|
assert.equal(m.f.halts.length,0);assert.deepEqual(m.receipts(m.f.ledger),[]);
|
|
if(m.g) assert.deepEqual(m.receipts(m.g.ledger),[]);
|
|
}
|
|
});
|
|
test('matching pin and ledger damage under a running service refuses every later tick',async t=>{
|
|
for(const kind of ['business','foreign']) {
|
|
const m=matchingDamage(t,kind),controller=new AbortController(),reports=[];
|
|
await serveBudget(m.f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[],halt:m.f.halt,report:r=>{
|
|
reports.push(r);if(reports.length===1)m.alter();if(reports.length===3)controller.abort();
|
|
}});
|
|
assert.equal(reports[0].stopped,false);
|
|
for(const r of reports.slice(1)) assert.equal(r.error,'campaign-instance-changed');
|
|
assert.equal(m.f.halts.length,0);assert.deepEqual(m.wrong(),[]);assert.deepEqual(m.receipts(m.f.ledger),[]);
|
|
}
|
|
});
|
|
test('a running service keeps its startup ledger when the caller mutates the options object',async t=>{
|
|
const f=setup(t,100),replacement=join(f.root,'replacement-ledger'),controller=new AbortController(),reports=[];
|
|
const startup=JSON.parse(readFileSync(join(f.ledger,'campaign.json'))),opts={...f.opts};let now=NOW;
|
|
await serveBudget(opts,{interval:1,signal:controller.signal,now:()=>now,read:()=>[],halt:f.halt,report:r=>{
|
|
reports.push(r);
|
|
if(reports.length===1) {
|
|
mkdirSync(replacement,{mode:0o700});
|
|
for(const n of readdirSync(f.ledger)) writeFileSync(join(replacement,n),readFileSync(join(f.ledger,n)),{mode:0o600});
|
|
const pin=JSON.parse(readFileSync(f.campaign+'.binding.json'));pin.ledger=replacement;writeFileSync(f.campaign+'.binding.json',JSON.stringify(pin));
|
|
opts.ledger=replacement;now=Date.parse(f.c.end);
|
|
}
|
|
if(reports.length===3)controller.abort();
|
|
}});
|
|
assert.equal(f.halts.length,2);for(const b of f.halts) assert.deepEqual(b,startup.binding);
|
|
const names=readdirSync(f.ledger).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n)).sort();
|
|
assert.deepEqual(names,['halt-1.json','halt-2.json','stop.json']);
|
|
const stop=JSON.parse(readFileSync(join(f.ledger,'stop.json')));
|
|
assert.equal(stop.reason,'campaign-ended');assert.deepEqual(stop.binding,startup.binding);
|
|
assert.deepEqual(readdirSync(replacement).filter(n=>n==='stop.json'||/^halt-\d+\.json$/.test(n)),[]);
|
|
});
|
|
test('an owner business or config retarget refuses without retained evidence and halts the retained target when running',async t=>{
|
|
for(const field of ['business','config']) {
|
|
const retarget=f=>{const g=setup(t,100,{business:'unrelated-business'}),c=JSON.parse(readFileSync(f.campaign));c[field]=field==='business'?g.c.business:g.config;writeFileSync(f.campaign,JSON.stringify(c));return g;};
|
|
let f=setup(t,100),g=retarget(f);
|
|
await assert.rejects(check(f.opts,{now:()=>NOW,read:()=>[row()],halt:f.halt}),/campaign-instance-changed/);
|
|
assert.equal(f.halts.length,0);assert.equal(g.halts.length,0);
|
|
f=setup(t,100);const startup=JSON.parse(readFileSync(join(f.ledger,'campaign.json'))),controller=new AbortController(),reports=[];
|
|
await serveBudget(f.opts,{interval:1,signal:controller.signal,now:()=>NOW,read:()=>[],halt:f.halt,report:r=>{
|
|
reports.push(r);if(reports.length===1)retarget(f);if(reports.length===3)controller.abort();
|
|
}});
|
|
assert.equal(reports[0].stopped,false);
|
|
for(const r of reports.slice(1)) { assert.equal(r.stopped,true);assert.equal(r.reason,'campaign-changed'); }
|
|
assert.equal(f.halts.length,2);for(const b of f.halts) assert.deepEqual(b,startup.binding);
|
|
}
|
|
});
|