char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
80904d5
main
1import type { Context } from "@earendil-works/chord" ; 2import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context" ; 3import { getOrThrow } from "@earendil-works/pi-durable/env" ; 4import type { Environment } from "@berry/api" ; 5import { createPatch } from "diff" ; 6import type { OpenFile , Save , Saved , WorkingCopy } from "./edit-types.ts" ; 7import { linesOf } from "./model.ts" ; 8import type { Shown } from "./types.ts" ; 9import { editable } from "./eligibility.ts" ; 10import { bounded , jjAt , jjRevision , show } from "./vcs.ts" ; 11 12const MAX_BYTES = 4 * 1024 * 1024 ; 13const mutations = new Map < string , Promise < unknown >>(); 14 15function working ( env :Environment , shown :Shown ) :WorkingCopy { 16return { 17machine :env . id , 18root :shown . root , 19vcs :shown . vcs , 20identity :shown . vcs === "jj" ?shown . revision . change ! . full :shown . log [ 1 ]?. commit ?. full ?? "" , 21}; 22} 23 24function validPath ( path :string ) { 25if ( 26! path || path . includes ( "\\" ) || path . includes ( "\0" ) || / ^[a-z]: / i . test ( path ) || 27path . split ( "/" ). some (( part ) => ! part || [ "." , ".." , ".git" , ".jj" ]. includes ( part . toLowerCase ())) 28) { 29throw new Error ( "Not an editable repository path." ); 30} 31} 32 33async function checkedPath ( env :Environment , root :string , path :string , context :Context ) { 34validPath ( path ); 35const canonicalRoot = getOrThrow ( await env . canonicalPath ( root , context )); 36const absolute = getOrThrow ( await env . joinPath ([ canonicalRoot , path ], context )); 37if ( getOrThrow ( await env . canonicalPath ( absolute , context )) !== absolute ) { 38throw new Error ( "Editing through symbolic links is not supported." ); 39} 40return absolute ; 41} 42 43async function read ( env :Environment , path :string , context :Context ) { 44const reader = getOrThrow ( await env . openBinaryReader ( path , { noFollow :true }, context )); 45let bytes :Uint8Array ; 46try { 47const info = getOrThrow ( await reader . info ( context )); 48if ( info . size > MAX_BYTES ) throw new Error ( "Diff editing is limited to 4 MiB text files." ); 49bytes = getOrThrow ( await reader . read ( 0 , info . size , context )); 50if ( bytes . length !== info . size ) throw new Error ( "File changed while reading; try again." ); 51} finally { 52await reader . close ( context ); 53} 54return await text ( new TextDecoder ( "utf-8" , { fatal :true , ignoreBOM :true }). decode ( bytes )); 55} 56 57async function text ( content :string ) { 58if ( content . includes ( "\0" )) throw new Error ( "Binary files cannot be edited." ); 59if ( content . includes ( "\r" ) && / \r(?!\n)|(?<!\r)\n / . test ( content )) { 60throw new Error ( "Mixed or bare-CR line endings are not supported by the diff editor." ); 61} 62const bytes = new TextEncoder (). encode ( content ); 63if ( bytes . length > MAX_BYTES ) throw new Error ( "Diff editing is limited to 4 MiB text files." ); 64return { content, version :await version ( bytes ) }; 65} 66 67async function version ( bytes :Uint8Array ) :Promise < string > { 68return Array . from ( new Uint8Array ( await crypto . subtle . digest ( "SHA-256" , bytes . slice ()))) 69. map (( byte ) => byte . toString ( 16 ). padStart ( 2 , "0" )). join ( "" ); 70} 71 72export async function openFile ( 73env :Environment , 74root :string , 75path :string , 76context :Context , 77route = "" , 78) :Promise < OpenFile > { 79if ( route !== "" ) return await openCommit ( env , root , path , route , context ); 80const shown = await show ( env , "" , 2 , context , true ); 81if ( shown . root !== root ) throw new Error ( "The conversation moved to another repository." ); 82const copy = working ( env , shown ); 83const file = shown . files . find (( file ) => file . path === path ); 84if ( ! file || ! editable ( file )) throw new Error ( "This is not an editable working-copy file." ); 85const absolute = await checkedPath ( env , root , path , context ); 86const current = await read ( env , absolute , context ); 87const lines = linesOf ( file ). hunks . flatMap (( hunk ) => hunk . lines ); 88const side = ( which :"old" | "new" ) => 89lines . filter (( line ) => line [ which ] !== null ) 90. map (( line ) => line . text + ( line . newline ?"\n" :"" )). join ( "" ); 91if ( 92lines . length && side ( "new" ). replace ( / \r\n / g , "\n" ) !== current . content . replace ( / \r\n / g , "\n" ) 93) { 94throw new Error ( "File changed while opening; refresh and try again." ); 95} 96const original = file . status === "added" ?"" :lines . length ?side ( "old" ) :current . content ; 97if ( new TextEncoder (). encode ( original ). length > MAX_BYTES ) { 98throw new Error ( "Diff editing is limited to 4 MiB on each side." ); 99} 100return { 101working :copy , 102file :bounded ([ file ])[ 0 ], 103 original, 104 ...current , 105}; 106} 107 108/** Run `task` after the ones before it under `key`. */ 109async function serialized < T > ( key :string , task :() => Promise < T >) :Promise < T > { 110const result = ( mutations . get ( key ) ?? Promise . resolve ()). then ( task ); 111const settled = result . catch (() => {}); 112mutations . set ( key , settled ); 113void settled . then (() => mutations . get ( key ) === settled && mutations . delete ( key )); 114return await result ; 115} 116 117function report ( path :string , before :string , after :string ) { 118const patch = before === after 119 ?"" 120 :createPatch ( path , before , after , "" , "" , { context :3 , timeout :1000 }); 121if ( patch === undefined ) throw new Error ( "Edit is too large to report; save a smaller change." ); 122return patch ; 123} 124 125export async function saveFile ( env :Environment , save :Save ) :Promise < Saved > { 126const bytes = new TextEncoder (). encode ( save . content ); 127if ( bytes . length > MAX_BYTES || save . content . includes ( "\0" )) { 128throw new Error ( "Diff editing requires a text file no larger than 4 MiB." ); 129} 130if ( save . working . change !== undefined ) return await saveCommit ( env , save ); 131return await serialized ( ` ${ env . id } \0 ${ save . working . root } \0 ${ save . path } ` , async () => { 132const shown = await show ( env , "" , 2 , BACKGROUND_CONTEXT ); 133const copy = working ( env , shown ); 134if ( 135( Object . keys ( copy ) as ( keyof WorkingCopy )[]). some (( key ) => copy [ key ] !== save . working [ key ]) 136) { 137throw new Error ( "The working copy changed; reopen the file before saving." ); 138} 139const absolute = await checkedPath ( env , shown . root , save . path , BACKGROUND_CONTEXT ); 140const before = await read ( env , absolute , BACKGROUND_CONTEXT ); 141if ( before . version !== save . version ) { 142throw new Error ( "This file changed on disk since you opened it." ); 143} 144const patch = report ( save . path , before . content , save . content ); 145// writing the existing file preserves its permissions; external writers still need conflict checks 146if ( patch ) getOrThrow ( await env . writeFile ( absolute , bytes , BACKGROUND_CONTEXT )); 147return { 148id :save . id , 149working :save . working , 150path :save . path , 151version :await version ( bytes ), 152 patch, 153}; 154}); 155} 156 157/** Reword one mutable jj change without overwriting a description edited elsewhere. */ 158export async function reword ( 159env :Environment , 160root :string , 161change :string , 162before :string , 163description :string , 164) :Promise < void > { 165await serialized ( ` ${ env . id } \0 ${ root } ` , async () => { 166const context = BACKGROUND_CONTEXT ; 167const current = await show ( env , change , 1 , context ); 168if ( current . root !== root ) throw new Error ( "The conversation moved to another repository." ); 169if ( current . vcs !== "jj" ) throw new Error ( "Only jj descriptions can be edited." ); 170const { revision} = current ; 171if ( revision . immutable ) throw new Error ( "This commit is immutable." ); 172if ( revision . description !== before ) { 173throw new Error ( "This description changed since you opened it. Reopen it before saving." ); 174} 175if ( description === before ) return ; 176await jjAt ( env , root , [ 177"describe" , 178revision . commit ! . full , 179`--message= ${ description } ` , 180], context ); 181}); 182} 183 184/** The last commit time a rewrite was given. */ 185let stamped = 0 ; 186 187/** A jj fileset naming exactly `path`. */ 188const fileset = ( path :string ) => `root-file: ${ JSON . stringify ( path )} ` ; 189 190async function openCommit ( 191env :Environment , 192root :string , 193path :string , 194route :string , 195context :Context , 196) :Promise < OpenFile > { 197validPath ( path ); 198const shown = await show ( env , route , 2 , context , true ); 199if ( shown . root !== root ) throw new Error ( "The conversation moved to another repository." ); 200const { revision} = shown ; 201if ( shown . vcs !== "jj" || revision . workingCopy ) throw new Error ( "Only jj commits can be edited." ); 202if ( revision . immutable ) throw new Error ( "This commit is immutable." ); 203const file = shown . files . find (( file ) => file . path === path ); 204if ( ! file || ! editable ( file )) throw new Error ( "This is not an editable file in this commit." ); 205const current = await text ( 206await jjAt ( env , root , [ 207"file" , 208"show" , 209"--ignore-working-copy" , 210`--revision= ${ revision . commit ! . full } ` , 211"--" , 212fileset ( path ), 213], context ), 214); 215const lines = linesOf ( file ). hunks . flatMap (( hunk ) => hunk . lines ); 216const original = file . status === "added" ?"" :lines . filter (( line ) => line . old !== null ) 217. map (( line ) => line . text + ( line . newline ?"\n" :"" )). join ( "" ); 218return { 219working :{ 220machine :env . id , 221 root, 222vcs :"jj" , 223identity :revision . commit ! . full , 224change :revision . change ! . full , 225}, 226file :bounded ([ file ])[ 0 ], 227 original, 228 ...current , 229}; 230} 231 232/** 233* Rewrite a jj commit's file through `jj diffedit`, its descendants rebasing onto it. The rewrite leaves the working 234* copy alone until it stands, so that one undone for the conflicts it would cause never touches the files on disk. 235*/ 236async function saveCommit ( env :Environment , save :Save ) :Promise < Saved > { 237validPath ( save . path ); 238const { root, change} = save . working ; 239// a rewrite moves every descendant, so one at a time per repository 240return await serialized ( ` ${ env . id } \0 ${ root } ` , async () => { 241const context = BACKGROUND_CONTEXT ; 242const jj = ( args :string []) => jjAt ( env , root , [ "--ignore-working-copy" , ...args ], context ); 243const revision = await jjRevision ( env , root , change ! , context , true ); 244if ( ! revision || revision . commit ! . full !== save . working . identity ) { 245throw new Error ( "This commit changed since you opened it." ); 246} 247if ( revision . immutable ) throw new Error ( "This commit is immutable." ); 248const before = await text ( 249await jj ([ "file" , "show" , `--revision= ${ revision . commit ! . full } ` , "--" , fileset ( save . path )]), 250); 251const patch = report ( save . path , before . content , save . content ); 252if ( ! patch ) { 253return { 254id :save . id , 255working :save . working , 256path :save . path , 257version :before . version , 258 patch, 259}; 260} 261const conflicted = async () => 262new Set ( 263( await jj ([ 264"log" , 265"--no-graph" , 266`--revisions=descendants( ${ change } ) & conflicts()` , 267'--template=change_id.short() ++ "\\n"' , 268])). split ( "\n" ). filter ( Boolean ), 269); 270const already = await conflicted (); 271const operation = ( await jj ([ "op" , "log" , "--no-graph" , "--limit=1" , "--template=id" ])). trim (); 272const staged = getOrThrow ( 273await env . joinPath ([ root , ".jj" , `berry-edit- ${ crypto . randomUUID ()} ` ], context ), 274); 275getOrThrow ( await env . writeFile ( staged , new TextEncoder (). encode ( save . content ), context )); 276try { 277await jj ([ 278"diffedit" , 279`--revision= ${ revision . commit ! . full } ` , 280"--tool=berry-write" , 281// commits keep whole seconds, so a save redone after the undo would make the same commit, which jj refuses 282`--config=debug.commit-timestamp= ${ 283new Date ( stamped = Math . max ( Math . floor ( Date . now () / 1000 ) * 1000 , stamped + 1000 )) 284. toISOString () 285} ` , 286'--config=merge-tools.berry-write.program="sh"' , 287`--config=merge-tools.berry-write.edit-args= ${ 288JSON . stringify ([ "-c" , 'cp "$0" "$1/$2"' , staged , "$right" , save . path ]) 289} ` , 290]); 291} finally { 292await env . exec ([ "rm" , "-f" , staged ], { cwd :root }, context ); 293} 294const caused = [ ...await conflicted ()]. filter (( id ) => ! already . has ( id )); 295if ( caused . length > 0 && ! save . force ) { 296await jj ([ "op" , "restore" , operation ]); 297throw new Error ( 298`Saving would cause conflicts in ${ caused . length } later commit ${ 299caused . length === 1 ? "" : "s" 300} ( ${ caused . join ( ", " )} ).` , 301); 302} 303// the working copy, if a descendant, moves onto the rewrite; this checks it out 304await jjAt ( env , root , [ "workspace" , "update-stale" ], context ); 305const rewritten = await jjRevision ( env , root , change ! , context , false ); 306return { 307id :save . id , 308working :{ ...save . working , identity :rewritten ! . commit ! . full }, 309path :save . path , 310version :( await text ( save . content )). version , 311 patch, 312}; 313}); 314}