Files
sausage/extensions/workspaces/workspaces.ts
T

138 lines
7.8 KiB
TypeScript

/** Workspace preparation, safe release, and synchronous child-to-parent fast-forwards. */
import { configure, defineTask, LiveDoc, InboxDoc, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { Workspaces } from './api.ts'
import { chain, git, mkdir, registered, resolve } from './tree.ts'
export function workspaces(host:any) {
const queues=host.resource('source-queues',()=>new Map<string,Promise<any>>())
const serial=async(source:string,operation:()=>Promise<any>)=>{
const pending=(queues.get(source)??Promise.resolve()).catch(()=>{}).then(operation)
queues.set(source,pending)
try{return await pending}finally{if(queues.get(source)===pending)queues.delete(source)}
}
const get=async(id:number,ctx:any)=>(await host.harness().snapshot(Workspaces,ROOT,ctx))?.items[id]
const Preparation=defineTask<any,any,null>({name:'workspaces.prepare',version:1,initial:()=>({phase:'prepare'}),phases:{
prepare:async(task:any,runtime:any,ctx:any)=>{
const workspace=await get(task.input.id,ctx)
const identity={task:task.id,conversations:await chain(host,task.input.id,ctx),signal:runtime.signal}
try {
await serial(workspace.source,async()=>{
await mkdir(host,'/worktrees')
if(!await registered(host,workspace)) {
// The preparation owns the branch name, but never adopts an unrelated directory.
const exists=await host.fs.stat(workspace.path).then(()=>true,(e:any)=>{if(e.code==='not_found')return false;throw e})
if(exists)throw new Error(`Workspace path already exists: ${workspace.path}`)
const existing=await git(host,workspace.source,['branch','--list',workspace.branch],identity)
if(existing)await git(host,workspace.source,['worktree','add',workspace.path,workspace.branch],identity)
else await git(host,workspace.source,['worktree','add','-b',workspace.branch,workspace.path,workspace.base],identity)
}
})
for(const command of host.settings().isolates?.worktree?.setup??[]) {
const result=await host.exec(command,{cwd:workspace.path,timeout:300,env:[['SAUSAGE_SOURCE',workspace.source]],...identity})
if(result.code)throw new Error(result.stderr||result.stdout||'Workspace setup failed')
}
await runtime.commit(async(tx:any)=>{
const saved=(await tx.doc(Workspaces,ROOT)).items[task.input.id]
saved.state='ready';delete saved.error
return {status:'terminal',outcome:{status:'completed',result:null}}
},ctx)
}catch(error){
if(runtime.signal.aborted)throw error
await runtime.commit(async(tx:any)=>{
const saved=(await tx.doc(Workspaces,ROOT)).items[task.input.id]
saved.state='failed';saved.error=String(error)
return {status:'terminal',outcome:{status:'completed',result:null}}
},ctx)
}
},
},abort:(_task:any,runtime:any,ctx:any)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx)})
const ready=async(id:number,ctx:any)=>{
const workspace=await get(id,ctx)
if(!workspace)return
if(workspace.state==='removed') {
await host.harness().commit(async(tx:any)=>{
const saved=(await tx.doc(Workspaces,ROOT)).items[id]
if(saved.state==='removed'){
saved.state='preparing'
saved.preparation=await tx.createTask(Preparation,{id},{conversationId:id,ownership:{kind:'conversation'},background:true})
}
},ctx)
}
const saved=await get(id,ctx)
if(saved.state==='preparing') {
host.harness().resume()
await host.harness().waitForTask(saved.preparation,ctx)
}
}
const unavailable=async(id:number,ctx:any)=>{
await ready(id,ctx)
const workspace=await get(id,ctx)
if(!workspace)return
if(workspace.state!=='ready')return `Workspace is ${workspace.state}: ${workspace.error??''}`
if(!await registered(host,workspace))return `Workspace checkout is missing or unregistered: ${workspace.path}`
}
const idle=async(id:number,ctx:any)=>{
if((await host.harness().snapshot(LiveDoc,id,ctx))?.run||(await host.harness().snapshot(InboxDoc,id,ctx))?.items.length)
throw new Error('Conversation is running or has queued input; wait or stop it first')
}
const discard=async(id:number,ctx:any)=>{
await idle(id,ctx)
const workspace=await get(id,ctx)
if(!workspace)throw new Error('Conversation has no workspace')
const identity={task:`discard-${id}`,conversations:await chain(host,id,ctx)}
return serial(workspace.source,async()=>{
if(workspace.state==='preparing')throw new Error('Workspace is preparing')
if(workspace.state!=='removed') {
await git(host,workspace.source,['worktree','remove',workspace.path],identity)
await host.harness().commit(async(tx:any)=>{(await tx.doc(Workspaces,ROOT)).items[id].state='removed'},ctx)
}
return {text:`Discarded isolate; kept branch ${workspace.branch}.`}
})
}
const release=async(ids:number[],ctx:any)=>{
const kept:any[]=[]
for(const id of ids) {
const workspace=await get(id,ctx)
if(!workspace||workspace.state==='removed')continue
try {
// Refuse detached work that its named branch would not retain.
const identity={task:`release-${id}`,conversations:await chain(host,id,ctx)}
await git(host,workspace.path,['merge-base','--is-ancestor','HEAD',workspace.branch],identity)
await git(host,workspace.source,['merge-base','--is-ancestor',workspace.branch,workspace.target],identity)
await discard(id,ctx)
}catch(error){kept.push({id,reason:String(error)})}
}
return kept
}
const land=async(id:number,ctx:any)=>{
await idle(id,ctx)
const workspace=await get(id,ctx)
if(workspace?.state==='removed'&&workspace.landed)return {text:`Landed commits from #${id} as ${workspace.landed}.`,landed:workspace.landed}
if(!workspace||workspace.state!=='ready')throw new Error('Conversation has no ready workspace')
const identity={task:`land-${id}`,conversations:await chain(host,id,ctx)}
return serial(workspace.source,async()=>{
if(await git(host,workspace.path,['status','--porcelain'],identity))throw new Error('Worktree is dirty')
if(await git(host,workspace.source,['symbolic-ref','--short','HEAD'],identity)!==workspace.target)throw new Error('Target branch changed')
if(await git(host,workspace.path,['symbolic-ref','--short','HEAD'],identity)!==workspace.branch)throw new Error('Workspace branch changed')
const tip=await git(host,workspace.path,['rev-parse','HEAD'],identity)
await git(host,workspace.source,['merge','--ff-only',tip],identity)
await host.harness().commit(async(tx:any)=>{(await tx.doc(Workspaces,ROOT)).items[id].landed=tip},ctx)
return {text:`Landed commits from #${id} as ${tip}.`,landed:tip}
})
}
return {Preparation,serial,get,ready,unavailable,land,discard,release,chain:(id:number,ctx:any)=>chain(host,id,ctx),
async create(tx:any,id:number,cwd:string,ancestors:string[]=[]) {
const identity={task:`workspace-${id}`,conversations:[String(id),...ancestors]}
const source=resolve(cwd,await git(host,cwd,['rev-parse','--show-toplevel'],identity))
const target=await git(host,cwd,['symbolic-ref','--quiet','--short','HEAD'],identity)
const base=await git(host,source,['rev-parse','HEAD'],identity)
const index=await tx.doc(Workspaces,ROOT)
if(index.items[id])throw new Error('Conversation already has a workspace')
const preparation=await tx.createTask(Preparation,{id},{conversationId:id,ownership:{kind:'conversation'},background:true})
const workspace={source,target,base,path:`/worktrees/${id}`,branch:`sausage/${id}`,state:'preparing',preparation}
index.items[id]=workspace
await configure(tx,id,{cwd:resolve(workspace.path,cwd.slice(source.length).replace(/^\//,''))})
return workspace
},
}
}