138 lines
7.8 KiB
TypeScript
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
|
|
},
|
|
}
|
|
}
|