Port workspaces and the durable lander into the world

This commit is contained in:
2026-11-07 15:15:53 +00:00
parent c8bf5e559e
commit fcab84012d
11 changed files with 32710 additions and 4 deletions
+46
View File
@@ -0,0 +1,46 @@
# Workspaces
An isolated child uses `/worktrees/<conversation-id>`.
The preparation task creates its Git checkout before the first tool.
The tool gate checks the registration through the VFS. It does not spawn Git.
Preparation and lander commands use world Git through `host.exec`.
Each command carries its task tag and the conversation chain of its caller.
`subagent start` accepts `isolate: "worktree"`.
A definition can select the same default.
`subagent land` fast-forwards the parent checkout and releases the child checkout.
`subagent discard` removes a clean checkout and retains its branch.
The service refuses release when the branch retains unlanded commits.
`workspace submit` requires a clean checkout and a divergence description.
Use `"None"` when the implementation matches the request.
The lander processes one FIFO queue per source checkout.
It merges the submitted commit in a detached checkout, runs checks, and fast-forwards the source.
A failed check returns an input to the submitter.
A successful landing leaves a report without another model turn.
It sends a one-way note to the branch starter through the messages API.
Both reports use stable request IDs.
The landing event lets lineage update the root tally and project memory.
The world settings file can contain this configuration:
```json
{
"lander": {
"checks": ["sh -c 'test -f feature.txt'", {"command": "sh -c 'true'", "timeout": 30}],
"push": "origin"
}
}
```
Checks run sequentially. The default timeout is 600 seconds.
Omit `push` to land locally.
Fetch, checks, and push use the submitter's conversation chain, not the lander conversation.
A push failure does not erase a local landing.
The queue records the checked candidate before the fast-forward.
It records `landed.sha` before push and report.
After a restart, the lander pushes and reports that exact SHA without another merge.
A later source commit does not change the SHA that the lander pushes.
The report uses a submission-scoped request ID to prevent duplicate delivery.
+12
View File
@@ -0,0 +1,12 @@
/** Durable workspace records and the landing event consumed by coordination extensions. */
import { defineDoc } from '@earendil-works/pi-durable'
import { defineEvent } from '@earendil-works/sausage-events'
export type Workspace = {source:string;target:string;path:string;branch:string;base?:string;state:'preparing'|'ready'|'failed'|'removed';preparation?:number;error?:string;landed?:string}
export type Submission = {id:string;conversationId:number;source:string;target:string;tip:string;at:number;divergence:string;title:string;conversations:string[];prepared?:{base:string;sha:string};landed?:{base:string;sha:string;commits:{sha:string;subject:string}[];paths:string[]};outcome?:any}
export const Workspaces = defineDoc<{items:Record<string,Workspace>;queue:Submission[];landers:Record<string,number>;landerConversations:Record<string,number>;results:Record<string,any>}>({
kind:'ultimate.workspaces',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,
initial:()=>({items:{},queue:[],landers:{},landerConversations:{},results:{}}),
})
export const WorkspacesApi = {name:'workspaces'}
export const SubmissionLanded = defineEvent<any>('workspaces.landed')
export const CheckRan = defineEvent<any>('workspaces.check')
+145
View File
@@ -0,0 +1,145 @@
/** A durable FIFO per source. A recorded landing always resumes at push/report, never at merge. */
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
import { configure, defineTask, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { CheckRan, SubmissionLanded, Workspaces } from './api.ts'
import { MessagesApi } from '../messages/api.ts'
import { starterOf } from '../messages/addresses.ts'
import { chain, git, hash, mkdir, registered } from './tree.ts'
export function lander(host:any,workspaces:any) {
const task=defineTask<any,any,null>({name:'workspaces.lander',version:1,initial:()=>({phase:'next'}),phases:{
next:(_task:any,runtime:any,ctx:any)=>runtime.commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
const entry=index.queue.find((entry:any)=>entry.source===_task.input.source)
if(!entry){delete index.landers[_task.input.source];return {status:'terminal',outcome:{status:'completed',result:null}}}
return {status:'running',checkpoint:{phase:'land',id:entry.id}}
},ctx),
land:async(task:any,runtime:any,ctx:any)=>{
const id=task.state.checkpoint.id
const entry={...(await runtime.snapshot(Workspaces,ROOT,ctx)).queue.find((entry:any)=>entry.id===id)}
const identity={task:task.id,conversations:entry.conversations,signal:runtime.signal}
const path=`/worktrees/lander-${hash(entry.source)}`
const run=(cwd:string,...args:string[])=>git(host,cwd,args,{...identity,timeout:['fetch','push'].includes(args[0])?300:30})
const save=async(update:any)=>runtime.commit(async(tx:any)=>{Object.assign((await tx.doc(Workspaces,ROOT)).queue.find((e:any)=>e.id===id),update);return {status:'running',checkpoint:{phase:'land',id}}},ctx)
let failure:string|undefined
let pushed:string|undefined
try {
if(!entry.landed)await workspaces.serial(entry.source,async()=>{
if(await run(entry.source,'symbolic-ref','--short','HEAD')!==entry.target)throw new Error('Target branch changed')
if(await run(entry.source,'status','--porcelain'))throw new Error('Target worktree is dirty')
const base=await run(entry.source,'rev-parse','HEAD')
if(!entry.prepared) {
await mkdir(host,'/worktrees')
if(!await registered(host,{source:entry.source,path}))await run(entry.source,'worktree','add','--detach',path,base)
await run(path,'reset','--hard',base)
const remote=host.settings().lander?.push
if(remote)await run(path,'fetch',remote,entry.target)
if(remote)await run(path,'merge','--no-edit','FETCH_HEAD')
await run(path,'merge','--no-ff','--no-edit','-m',`Merge from #${entry.conversationId} into ${entry.target}`,entry.tip)
const sha=await run(path,'rev-parse','HEAD')
for(const configured of host.settings().lander?.checks??[]) {
const check=typeof configured==='string'?{command:configured}:configured
const start=Date.now()
let result:any
let error:any
try {result=await host.exec(check.command,{cwd:path,timeout:check.timeout??600,...identity})}catch(cause){error=cause}
const passed=!error&&result.code===0
await runtime.commit(async(tx:any)=>{
await host.emit(tx,CheckRan,{submission:id,command:check.command,result:passed?'passed':error?'timeout':'failed',ms:Date.now()-start})
return {status:'running',checkpoint:{phase:'land',id}}
},ctx)
if(!passed)throw new Error(`Check ${check.command} failed: ${String(error??(result.stderr||result.stdout)).slice(-4000)}`)
}
if(await run(path,'status','--porcelain')||await run(path,'rev-parse','HEAD')!==sha)throw new Error('Check changed the lander checkout')
entry.prepared={base,sha}
await save({prepared:entry.prepared})
}
const {sha,base:previous}=entry.prepared
// Recover a fast-forward before its document commit, even when another commit followed it.
const current=await run(entry.source,'rev-parse','HEAD')
if(current===previous)await run(entry.source,'merge','--ff-only',sha)
else if(current!==sha)await run(entry.source,'merge-base','--is-ancestor',sha,'HEAD')
const commits=(await run(path,'log','--format=%H %s',`${previous}..${sha}`)).split('\n').filter(Boolean).map(line=>({sha:line.slice(0,40),subject:line.slice(41)}))
const paths=(await run(path,'diff','--name-only',previous,sha)).split('\n').filter(Boolean)
entry.landed={base:previous,sha,commits,paths}
await save({landed:entry.landed})
})
const remote=host.settings().lander?.push
if(remote) {
// World git accepts HEAD, not raw object IDs. Pin the detached lander checkout, never the moving source branch.
await run(path,'checkout','--detach',entry.landed.sha)
await run(path,'push',remote,`HEAD:refs/heads/${entry.target}`)
pushed=`${remote}/${entry.target}`
}
}catch(error){if(runtime.signal.aborted)throw error;failure=String(error)}
const outcome=entry.landed?{outcome:'landed',...entry.landed,...(pushed?{pushed}:{}),...(failure?{reason:`Push failed: ${failure}`}:{})}:{outcome:'failed',reason:failure}
const text=entry.landed?`[lander] Submission ${id} landed as ${entry.landed.sha} on ${entry.target}. ${pushed?`Pushed to ${pushed}.`:failure?`Push failed: ${failure}`:'Not pushed.'}`:`[lander] Submission ${id} was not landed: ${failure}`
await runtime.commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
const recorded=index.results[id]
index.results[id]=outcome
if(entry.landed){
index.items[entry.conversationId].landed=entry.landed.sha
// Lineage records the landing in the root tally and distills its memory.
if(!recorded)await host.emit(tx,SubmissionLanded,{submission:id,conversationId:entry.conversationId,at:entry.at,sha:entry.landed.sha,base:entry.landed.base,target:entry.target,lander:runtime.conversationId,divergence:entry.divergence,title:entry.title,commits:entry.landed.commits.slice(0,10),commitCount:entry.landed.commits.length,paths:entry.landed.paths.slice(0,8),...(pushed?{pushed}:{})})
}
return {status:'running',checkpoint:{phase:'report',id,conversationId:entry.conversationId,text,outcome}}
},ctx)
},
report:async(task:any,runtime:any,ctx:any)=>{
const {id,conversationId,text,outcome}=task.state.checkpoint
const conversation=await runtime.conversation(conversationId,ctx)
const entry={kind:'workspaces.lander-report',data:{submission:id,...outcome}}
if(outcome.outcome==='failed')
await conversation.submit({type:'input',content:text,entry,whenBusy:'followUp',requestId:`lander:${id}`},ctx)
else {
await conversation.submit({type:'write',entry:{...entry,model:[{role:'user',content:text,timestamp:Date.now()}]},requestId:`lander:${id}`},ctx)
const starter=await starterOf(host,conversationId,ctx)
if(starter!==undefined&&starter!==conversationId)
await host.need(MessagesApi).send(conversationId,{to:`#${starter}`,text,reply:false},`lander:${id}`,ctx)
}
await runtime.commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
index.queue=index.queue.filter((entry:any)=>entry.id!==id)
return {status:'running',checkpoint:{phase:'next'}}
},ctx)
},
},abort:(_task:any,runtime:any,ctx:any)=>runtime.commit(async(tx:any)=>{delete (await tx.doc(Workspaces,ROOT)).landers[_task.input.source];return {status:'terminal',outcome:{status:'aborted'}}},ctx)})
const ensure=async(tx:any,index:any,source:string,known?:any)=>{
const existing=known===undefined?(index.landers[source]&&await tx.task(index.landers[source])):known
if(existing&&existing.state.status!=='terminal')return
if(!index.landerConversations[source]){
const conversation=await tx.createConversation({ownership:{kind:'ownerless'}})
index.landerConversations[source]=conversation.id
await configure(tx,conversation.id,{cwd:`/worktrees/lander-${hash(source)}`})
Object.assign(await tx.doc(host.metadata,conversation.id),{title:'@lander',label:'lander'})
}
index.landers[source]=await tx.createTask(task,{source},{conversationId:index.landerConversations[source],ownership:{kind:'conversation'},background:true})
}
host.onOpen(()=>host.harness().commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
const sources=[...new Set<string>(index.queue.map((entry:any)=>entry.source))]
const tasks=await Promise.all(sources.map(source=>index.landers[source]?tx.task(index.landers[source]):null))
for(let i=0;i<sources.length;i++)await ensure(tx,index,sources[i],tasks[i]??null)
},BACKGROUND_CONTEXT))
return {task,async submit(id:number,divergence:string,ctx:any,requestId?:string){
const unavailable=await workspaces.unavailable(id,ctx)
if(unavailable)throw new Error(unavailable)
const workspace=await workspaces.get(id,ctx)
if(!workspace)throw new Error('This conversation has no workspace')
const conversations=await chain(host,id,ctx)
const identity={task:`submit-${id}`,conversations}
if(await git(host,workspace.path,['status','--porcelain'],identity))throw new Error('Commit work before submitting')
const tip=await git(host,workspace.path,['rev-parse','HEAD'],identity)
return host.harness().commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
if(requestId&&index.results[requestId])return {id:requestId,position:0}
let entry=index.queue.find((entry:any)=>entry.conversationId===id)
if(!entry){
entry={id:requestId??globalThis.crypto.randomUUID(),conversationId:id,source:workspace.source,target:workspace.target,tip,conversations,divergence,title:(await tx.doc(host.metadata,id)).title??'',at:await tx.latestEntryId(id)??0}
index.queue.push(entry)
}
await ensure(tx,index,workspace.source)
return {id:entry.id,position:index.queue.filter((item:any)=>item.source===workspace.source).findIndex((item:any)=>item.id===entry.id)+1}
},ctx)
}}
}
+31
View File
@@ -0,0 +1,31 @@
/** World-loaded worktree tools and the VFS-only gate before every guest tool. */
import { defineExtension, defineTool, hook, ToolTask, GenerationTask, ROOT_CONVERSATION_ID, section } from '@earendil-works/pi-durable'
import { obj, oneOf, opt, str } from '@earendil-works/pi-schema'
import { Workspaces, WorkspacesApi } from './api.ts'
import { workspaces } from './workspaces.ts'
import { lander } from './lander.ts'
export const order=-200
export const docs=[Workspaces]
export default function(host:any) {
const service=workspaces(host), landing=lander(host,service)
host.implement(WorkspacesApi,{...service,submit:landing.submit,doc:Workspaces})
return defineExtension({name:'workspaces',tasks:[service.Preparation,landing.task],
hooks:[hook(ToolTask,{beforeTool:async(_call:any,api:any,ctx:any)=>{
const reason=await service.unavailable(api.conversationId,ctx)
return reason?{block:reason}:undefined
}}),hook(GenerationTask,{beforeInput:async(_input:any,api:any)=>{
const index=await api.tx.doc(Workspaces,ROOT_CONVERSATION_ID)
if(Object.values(index.landerConversations).includes(api.conversationId))throw new Error('The lander runs no model')
}})],
sections:[section('workspace',async(input:any,ctx:any)=>{
await service.ready(input.conversationId,ctx)
const workspace=await service.get(input.conversationId,ctx)
return workspace?`Workspace: ${workspace.path} (${workspace.state}), branch ${workspace.branch}, target ${workspace.target}.`:''
})],
tools:[defineTool({name:'workspace',description:'Inspect your workspace or submit its clean tip to the lander. State any divergence from the request.',parameters:obj({action:oneOf(['status','submit']),divergence:opt(str())}),replay:'safe',execute:async(args:any,api:any,ctx:any)=>{
if(args.action==='submit'&&!args.divergence?.trim())throw new Error('State divergence, or None')
const result=args.action==='submit'?await landing.submit(api.conversationId,args.divergence,ctx,`workspace:${api.conversationId}:${api.callId}`):await service.get(api.conversationId,ctx)
return {output:[{type:'text',text:JSON.stringify(result??{})}]}
}})],
})
}
+50
View File
@@ -0,0 +1,50 @@
/** World Git execution, inherited caller identity, and VFS-only checkout registration checks. */
import { Started } from '../messages/addresses.ts'
export const resolve = (base:string,path:string) => {
const parts:string[]=[]
for(const part of (path.startsWith('/')?path:`${base}/${path}`).split('/')) {
if(part==='..')parts.pop();else if(part&&part!=='.')parts.push(part)
}
return '/'+parts.join('/')
}
export async function chain(host:any,id:number,context:any):Promise<string[]> {
const ids=new Set<number>()
const visit=async(id:number)=>{
if(ids.has(id))return
ids.add(id)
const row=await host.harness().commit((tx:any)=>tx.conversation(id),context)
const up=row?.owner?.conversationId??row?.parent?.conversationId
if(up!==undefined)await visit(up)
const starter=(await host.harness().snapshot(Started,id,context))?.by
if(starter!==undefined)await visit(starter)
}
await visit(id)
return [...ids].map(String)
}
export const git = async(host:any,cwd:string,args:string[],identity:any={})=>{
const result=await host.exec(['git',...args],{cwd,timeout:30,...identity})
if(result.code)throw new Error(`${cwd}: git ${args.join(' ')}: ${result.stderr||result.stdout||`exited ${result.code}`}`)
return result.stdout.trim()
}
export async function mkdir(host:any,path:string) {
let current=''
for(const part of path.split('/').filter(Boolean)) {
current+='/'+part
try {await host.fs.mkdir(current)} catch(error) {if((error as any).code!=='already_exists')throw error}
}
}
export async function gitdir(host:any,source:string) {
const path=`${source}/.git`
return (await host.fs.stat(path)).kind==='directory'?path:resolve(source,(await host.fs.read(path)).trim().replace(/^gitdir: /,''))
}
export async function registered(host:any,workspace:{source:string;path:string}) {
try {
const admin=await gitdir(host,workspace.path)
const source=await gitdir(host,workspace.source)
const common=await host.fs.read(`${source}/commondir`).then((p:string)=>resolve(source,p.trim()),(e:any)=>{if(e.code==='not_found')return source;throw e})
return admin.startsWith(`${common}/worktrees/`) &&
resolve(admin,(await host.fs.read(`${admin}/gitdir`)).trim())===`${workspace.path}/.git` &&
(await host.fs.stat(`${admin}/HEAD`)).kind==='file'
} catch(error) {if((error as any).code==='not_found')return false;throw error}
}
export const hash=(text:string)=>{let hash=2166136261;for(const c of text)hash=Math.imul(hash^c.charCodeAt(0),16777619);return (hash>>>0).toString(16)}
+137
View File
@@ -0,0 +1,137 @@
/** 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
},
}
}