/** 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>()) const serial=async(source:string,operation:()=>Promise)=>{ 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({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 }, } }