162 lines
12 KiB
TypeScript
162 lines
12 KiB
TypeScript
/** Scripted acceptance cases run through the world extension loader and real guest Git. */
|
|
import { BACKGROUND_CONTEXT as context, awaitWithContext } from '@earendil-works/chord/context';
|
|
import { createModels } from '@earendil-works/pi-ai/models';
|
|
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
|
import { Harness, createRegistry, defineExtension, defineTool } from '@earendil-works/pi-durable';
|
|
import { obj, str } from '@earendil-works/pi-schema';
|
|
import { loadExtensions, fs } from './extensions';
|
|
import { openStorage } from './sqlite';
|
|
const assert=(value:any,message:string)=>{if(!value)throw new Error(message)};
|
|
const models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider);
|
|
const registry=createRegistry(),storage=await openStorage(globalThis.storagePath);
|
|
const harness=await Harness.open(storage,{models,registry,settings:{get extensions(){return registry.snapshot().installed()}}},context);
|
|
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'},cwd:'/project'}});
|
|
if(globalThis.workspacesMode==='kill') {
|
|
const install=registry.install.bind(registry);
|
|
registry.install=(extension:any)=>install(extension.name!=='workspaces'?extension:{...extension,tasks:extension.tasks.map((task:any)=>task.definition.name!=='workspaces.lander'?task:{definition:{...task.definition,phases:{...task.definition.phases,land:async(record:any,runtime:any,ctx:any)=>{
|
|
const commit=runtime.commit.bind(runtime);
|
|
await taskDefinition(record,runtime,ctx);
|
|
async function taskDefinition(record:any,rt:any,c:any){
|
|
await task.definition.phases.land(record,{...rt,commit:async(...args:any[])=>{
|
|
await commit(...args);
|
|
const index=await harness.snapshot(globalThis.workspaceService.doc,root.id,context);
|
|
if(index.queue.some((entry:any)=>entry.landed)) {
|
|
await fs.write('/push-ready','yes');await awaitWithContext(new Promise(()=>{}),c);
|
|
}
|
|
}},c);
|
|
}
|
|
}}}})});
|
|
}
|
|
const probe=defineExtension({name:'guest',tools:[defineTool({name:'implement',description:'Write and commit in the prepared checkout',parameters:obj({name:str()}),replay:'safe',execute:async(args:any,api:any,ctx:any)=>{
|
|
const cwd=(await api.agent(ctx)).cwd;
|
|
assert(cwd.startsWith('/worktrees/'),'First tool needs isolated cwd');
|
|
assert((await fs.stat(`${cwd}/base.txt`)).kind==='file','Checkout must exist before first tool');
|
|
const workspace=await globalThis.workspaceService.get(api.conversationId,ctx);
|
|
assert(workspace.state==='ready','Preparation must finish');
|
|
await fs.write(`${cwd}/${args.name}`,'feature');
|
|
for(const args of [['add','.'],['-c','user.name=Test','-c','user.email=test@example.com','commit','--no-gpg-sign','-m','feature']])await globalThis.workspaceHost.git(cwd,...args);
|
|
return {output:[{type:'text',text:'committed'}]};
|
|
}})]});
|
|
const route=(transcript:any):any=>{
|
|
faux.appendResponses([route]);
|
|
const last=transcript.messages.findLast((message:any)=>message.role!=='system');
|
|
const text=typeof last.content==='string'?last.content:last.content.filter((part:any)=>part.type==='text').map((part:any)=>part.text).join('');
|
|
if(last.role==='user'&&text.startsWith('CALL ')){const {tool,args}=JSON.parse(text.slice(5));return fauxAssistantMessage([fauxToolCall(tool,args)],{stopReason:'toolUse'});}
|
|
if(last.role==='user'&&text==='IMPLEMENT')return fauxAssistantMessage([fauxToolCall('implement',{name:'feature.txt'})],{stopReason:'toolUse'});
|
|
if(last.role==='user'&&text==='BROKEN')return fauxAssistantMessage([fauxToolCall('implement',{name:'broken.txt'})],{stopReason:'toolUse'});
|
|
return fauxAssistantMessage('Done');
|
|
};faux.setResponses([route]);
|
|
const loader=await loadExtensions(harness,registry,storage,models,context,[probe]);
|
|
assert(!loader.state().error,`Load: ${loader.state().error}`);
|
|
const service=globalThis.workspaceService,host=globalThis.workspaceHost;
|
|
const call=async(tool:string,args:any)=>{
|
|
await(await root.submit({type:'input',content:`CALL ${JSON.stringify({tool,args})}`},context)).wait(context);
|
|
const result=(await root.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
|
|
assert(!result?.isError,`Tool error: ${JSON.stringify(result)}`);
|
|
return result;
|
|
};
|
|
const submit=async(id:number)=>{
|
|
const {Started}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/messages/addresses.ts`);
|
|
await harness.commit(async(tx:any)=>{(await tx.doc(Started,id)).by=root.id},context);
|
|
const child=await harness.conversation(id,context);
|
|
await(await child.submit({type:'input',content:`CALL ${JSON.stringify({tool:'workspace',args:{action:'submit',divergence:'None'}})}`},context)).wait(context);
|
|
const result=(await child.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
|
|
assert(!result?.isError,`Workspace tool: ${JSON.stringify(result)}`);
|
|
return JSON.parse(result.content.filter((part:any)=>part.type==='text').map((part:any)=>part.text).join(''));
|
|
};
|
|
const children=async()=>{
|
|
const {Subagents}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/subagents/api.ts`);
|
|
return Object.values((await harness.snapshot(Subagents,root.id,context)).agents) as any[];
|
|
};
|
|
const waitQueue=async()=>{
|
|
const deadline=Date.now()+30000;
|
|
while((await harness.snapshot(service.doc,root.id,context)).queue.length) {
|
|
if(Date.now()>deadline)throw new Error(`Lander deadline: ${JSON.stringify(await harness.snapshot(service.doc,root.id,context))}`);
|
|
await new Promise(r=>setTimeout(r,0));
|
|
}
|
|
await harness.waitForIdle(context);
|
|
};
|
|
const reports=async(id:number)=>(await(await harness.conversation(id,context)).context(context)).entries.filter((entry:any)=>entry.kind==='workspaces.lander-report');
|
|
const notes=async()=>{
|
|
const {Messages}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/messages/api.ts`);
|
|
const deadline=Date.now()+30000;
|
|
for(;;) {
|
|
const notes=((await harness.snapshot(Messages,root.id,context)).messages as any[]).filter(message=>message.text.startsWith('[lander] Submission')&&message.text.includes(' landed as '));
|
|
if(!notes.some(note=>note.state==='sent'))return notes;
|
|
assert(Date.now()<deadline,`Landing note delivery: ${JSON.stringify(notes)}`);
|
|
await new Promise(r=>setTimeout(r,0));
|
|
}
|
|
};
|
|
harness.resume();
|
|
try {
|
|
if(globalThis.workspacesMode==='resume') {
|
|
await waitQueue();
|
|
const index=await harness.snapshot(service.doc,root.id,context);
|
|
const result=Object.values(index.results)[0] as any;
|
|
assert(result?.outcome==='landed',`Recovery result ${JSON.stringify(index)}`);
|
|
const [child]=await children();
|
|
const report=await reports(child.conversationId);
|
|
assert(report.length===1,'Restart must report once');
|
|
assert(report[0].data.sha===result.sha,'Report must retain landed.sha');
|
|
const delivered=await notes();
|
|
assert(delivered.length===1&&delivered[0].state==='delivered'&&delivered[0].text.includes(result.sha),`Restart must notify the starter once with landed.sha: ${JSON.stringify(delivered)}`);
|
|
assert(await host.git('/remote.git','rev-parse','main')===result.sha,`Push must retain landed.sha: ${JSON.stringify(result)}`);
|
|
assert((await host.git('/project','log','--format=%s')).split('\n').filter((line:string)=>line.startsWith('Merge from')).length===1,'Restart must not merge again');
|
|
assert((await host.eventLog.recent({events:[{kind:'event.workspaces.landed'}]})).length===1,'Restart must emit once');
|
|
} else {
|
|
await call('subagent',{action:'start',agent:'worker',task:globalThis.workspacesMode==='normal'?'BROKEN':'IMPLEMENT'});
|
|
if(globalThis.workspacesMode==='normal') {
|
|
const [broken]=await children();
|
|
const before=globalThis.workspaceExecutions.length;
|
|
assert(!await service.unavailable(broken.conversationId,context),'Ready VFS gate');
|
|
assert(globalThis.workspaceExecutions.length===before,'Ready gate must not spawn Git');
|
|
const checkout=await service.get(broken.conversationId,context);
|
|
await fs.rename(`${checkout.path}/.git`,`${checkout.path}/.git.saved`);
|
|
assert((await service.unavailable(broken.conversationId,context)).includes('missing or unregistered'),'Gate must reject missing registration');
|
|
const blocked=await harness.conversation(broken.conversationId,context);
|
|
await(await blocked.submit({type:'input',content:`CALL ${JSON.stringify({tool:'implement',args:{name:'guard.txt'}})}`},context)).wait(context);
|
|
const blockedResult=(await blocked.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
|
|
assert(blockedResult.isError&&JSON.stringify(blockedResult).includes('missing or unregistered'),'beforeTool must block the guest tool');
|
|
assert(!await fs.stat(`${checkout.path}/guard.txt`).then(()=>true,(error:any)=>{if(error.code==='not_found')return false;throw error}),'Blocked tool must not write');
|
|
await fs.rename(`${checkout.path}/.git.saved`,`${checkout.path}/.git`);
|
|
assert(globalThis.workspaceExecutions.length===before,'Failed VFS gate must not spawn Git');
|
|
const failed=await submit(broken.conversationId);
|
|
await waitQueue();
|
|
const result=(await harness.snapshot(service.doc,root.id,context)).results[failed.id];
|
|
assert(result.outcome==='failed'&&result.reason.includes('Check sh'),`Check must fail: ${JSON.stringify(result)}`);
|
|
assert((await reports(broken.conversationId)).length===1,'Failed check must return to submitter');
|
|
const failedWorkspace=await service.get(broken.conversationId,context);
|
|
assert((await service.release([broken.conversationId],context)).length===1,'Release must retain unlanded work');
|
|
await call('subagent',{action:'discard',id:broken.conversationId});
|
|
assert(await host.git('/project','rev-parse',failedWorkspace.branch),'Discard must retain its branch');
|
|
await call('subagent',{action:'start',agent:'worker',task:'IMPLEMENT'});
|
|
const child=(await children()).at(-1);
|
|
await call('subagent',{action:'land',id:child.conversationId});
|
|
assert(await fs.read('/project/feature.txt')==='feature','Parent fast-forward');
|
|
assert((await service.get(child.conversationId,context)).state==='removed','Land must release checkout');
|
|
await call('subagent',{action:'start',agent:'worker',task:'BROKEN'});
|
|
}
|
|
const submitter=(await children()).at(-1).conversationId;
|
|
const submission=await submit(submitter);
|
|
if(globalThis.workspacesMode==='kill')await new Promise(()=>{});
|
|
await waitQueue();
|
|
const result=(await harness.snapshot(service.doc,root.id,context)).results[submission.id];
|
|
// feature.txt came from synchronous landing, so this submission passes its real shell check too.
|
|
assert(result.outcome==='landed',`Landing failed: ${JSON.stringify(result)}`);
|
|
assert(await host.git('/remote.git','rev-parse','main')===result.sha,`Local bare push: ${JSON.stringify(result)}`);
|
|
assert((await reports(submitter)).length===1,'Submission report');
|
|
const delivered=await notes();
|
|
assert(delivered.length===1&&delivered[0].state==='delivered'&&delivered[0].from===submitter&&delivered[0].to===root.id&&delivered[0].reply===false&&delivered[0].text.includes(result.sha),`Landing note must use the messages API: ${JSON.stringify(delivered)}`);
|
|
const commands=globalThis.workspaceExecutions.filter((entry:any)=>typeof entry.command==='string'||entry.command[1]==='fetch'||entry.command[1]==='push');
|
|
const push=commands.findLast((entry:any)=>entry.command[1]==='push');
|
|
assert(push,'Real push must run');
|
|
assert(JSON.stringify(push.options.conversations)===JSON.stringify([String(submitter),String(root.id)]),'Push must use the exact submitter chain');
|
|
for(const entry of commands) {
|
|
assert(entry.options.task!==undefined,'Lander exec needs its task tag');
|
|
if(typeof entry.command==='string')assert(entry.options.timeout===600,'Checks need a bounded default timeout');
|
|
assert(entry.options.conversations[1]===String(root.id),'Lander exec needs the submitter ancestor');
|
|
assert(entry.options.conversations[0]!==String(root.id),'Lander exec needs the submitter first');
|
|
}
|
|
}
|
|
}finally{await loader.close();await harness.close(context)}
|