char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
1069809
main
1import { 2Error as ErrorCtor , 3Map , 4Object , 5String , 6TypeError , 7} from "ext:core/primordials" ; 8import { promiseAll } from "ext:core/promises" ; 9import { 10op_console_print , 11op_task_begin , 12op_task_digest , 13op_task_drop , 14op_task_finish , 15op_task_relevant , 16op_task_step , 17} from "ext:core/ops" ; 18import type { 19TaskActionJson , 20TaskGraphJson , 21TaskReadyJson , 22TaskReportJson , 23} from "ext:core/ops" ; 24import { spawn } from "ext:process/main.js" ; 25import { watchFs } from "ext:file/main.js" ; 26import type { AbortSignal } from "ext:web/events.js" ; 27import { env } from "ext:base/env.js" ; 28import { process , runtime } from "ext:base/host.js" ; 29import { clearTimeout , setTimeout } from "ext:timers/timers.js" ; 30 31/** The subset of `AbortSignal` that `avril:task` relies on; a real `AbortSignal` satisfies it. */ 32export interface AbortSignalLike { 33readonly aborted :boolean ; 34} 35 36/** The context handed to a function task when it runs. */ 37export interface TaskContext { 38readonly name :string ; 39/** The directory task paths are relative to. */ 40readonly root :string ; 41/** Extra arguments; non-empty only for the run's target. */ 42readonly args :readonly string []; 43/** The task's effective environment: file defaults under the process environment. */ 44readonly env :Record < string , string >; 45readonly signal ?:AbortSignalLike ; 46} 47 48export interface TaskDef { 49description ?:string ; 50/** A shell string (`sh -c` / `cmd /C`), or an argv array spawned without a shell. */ 51command ?:string | string []; 52/** An in-process action. Return a number to use it as the exit code. */ 53run ?:( ctx :TaskContext ) => void | number | Promise < void | number >; 54dependencies ?:string []; 55parallel ?:boolean ; 56envFile ?:string ; 57/** Declaring `files` opts the task into result caching. */ 58files ?:string | string []; 59output ?:string | string []; 60env ?:string | string []; 61} 62 63export interface FailedTaskInfo { 64name :string ; 65code :number ; 66} 67 68export interface TaskReport { 69readonly code :number ; 70readonly ok :boolean ; 71readonly completed :string []; 72readonly failed :FailedTaskInfo []; 73readonly cached :string []; 74/** Messages from function tasks that threw, by task name. */ 75readonly errors :Record < string , string >; 76/** Throws a `TaskRunError` carrying this report when the run failed. */ 77throwIfFailed () :void ; 78} 79 80/** The error thrown by `TaskReport.throwIfFailed()`. */ 81export interface TaskRunError extends Error { 82readonly name :"TaskRunError" ; 83readonly report :TaskReport ; 84} 85 86export type TaskEvent = 87| { type :"started" ; name :string } 88| { type :"cached" ; name :string } 89| { type :"finished" ; name :string ; code :number } 90| { type :"error" ; name :string ; message :string }; 91 92export interface RunOptions { 93/** The task to run. Defaults to `"all"`. */ 94target ?:string ; 95/** Directory task paths are relative to. Defaults to the working directory. */ 96root ?:string ; 97/** Extra arguments forwarded to the target. */ 98args ?:string []; 99/** Run cacheable tasks even when their outputs are intact. */ 100force ?:boolean ; 101/** Task cache directory. Defaults to avril's shared task cache. */ 102cacheDir ?:string ; 103/** Rerun when task inputs change, re-evaluating the graph each time. */ 104watch ?:boolean ; 105signal ?:AbortSignalLike ; 106onEvent ?:( event :TaskEvent ) => void ; 107} 108 109export type GraphInput = 110| Record < string , TaskDef > 111| (() => Record < string , TaskDef > | Promise < Record < string , TaskDef >>); 112 113type TaskFn = ( ctx :TaskContext ) => void | number | Promise < void | number >; 114 115function fail ( path :string , message :string ) :never { 116throw new TypeError ( `avril:task: ${ path } : ${ message } ` ); 117} 118 119function list ( value :string | string [] | undefined , path :string ) :string [] | undefined { 120if ( value === undefined ) return undefined ; 121const items = typeof value === "string" ?[ value ] :value ; 122if ( ! Array . isArray ( items ) || items . some (( item ) => typeof item !== "string" || item === "" )) { 123fail ( path , "expected a string or an array of non-empty strings" ); 124} 125return [ ...items ]; 126} 127 128interface Prepared { 129graph :TaskGraphJson ; 130functions :Map < number , TaskFn >; 131} 132 133async function prepare ( input :GraphInput ) :Promise < Prepared > { 134const definitions = typeof input === "function" ?await input () :input ; 135if ( typeof definitions !== "object" || definitions === null || Array . isArray ( definitions )) { 136fail ( "graph" , "expected an object mapping task names to tasks" ); 137} 138const functions = new Map < number , TaskFn >(); 139const tasks :TaskGraphJson [ "tasks" ] = {}; 140let nextId = 0 ; 141for ( const [ name , definition ] of Object . entries ( definitions )) { 142const path = `tasks. ${ name } ` ; 143if ( typeof definition !== "object" || definition === null ) { 144fail ( path , "expected a task object" ); 145} 146const hasCommand = definition . command !== undefined ; 147const hasRun = definition . run !== undefined ; 148if ( hasCommand && hasRun ) { 149fail ( path , "use either command or run, not both" ); 150} 151let action :TaskActionJson ; 152if ( ! hasCommand && ! hasRun ) { 153action = "None" ; 154} else if ( hasRun ) { 155if ( typeof definition . run !== "function" ) fail ( ` ${ path } .run` , "expected a function" ); 156const id = nextId ++ ; 157functions . set ( id , definition . run ); 158// The function source is its fingerprint identity: editing the body 159// invalidates the task; editing its imports invalidates only via `files`. 160action = { Callback :{ id, key :String ( definition . run ) } }; 161} else if ( typeof definition . command === "string" ) { 162if ( definition . command . trim () === "" ) fail ( ` ${ path } .command` , "command cannot be empty" ); 163action = { Shell :definition . command }; 164} else { 165const argv = list ( definition . command , ` ${ path } .command` ); 166if ( ! argv || argv . length === 0 ) fail ( ` ${ path } .command` , "command array cannot be empty" ); 167action = { Argv :argv }; 168} 169tasks [ name ] = { 170description :definition . description ?? null , 171 action, 172dependencies :list ( definition . dependencies , ` ${ path } .dependencies` ) ?? [], 173parallel :definition . parallel ?? false , 174env_file :definition . envFile ?? null , 175files :list ( definition . files , ` ${ path } .files` ) ?? null , 176output :list ( definition . output , ` ${ path } .output` ) ?? [], 177env :list ( definition . env , ` ${ path } .env` ) ?? [], 178}; 179} 180return { graph :{ tasks}, functions}; 181} 182 183function shellQuote ( arg :string ) :string { 184if ( runtime . os === "windows" ) return `" ${ arg . replaceAll ( '"' , '\\"' )} "` ; 185if ( / ^[A-Za-z0-9._\-/:@%+=,]+$ / . test ( arg )) return arg ; 186return `' ${ arg . replaceAll ( "'" , "'\\''" )} '` ; 187} 188 189function environmentFor ( defaults :Record < string , string >) :Record < string , string > { 190const result :Record < string , string > = {}; 191for ( const [ key , value ] of Object . entries ( defaults )) { 192let existing :string | undefined ; 193try { 194existing = env . get ( key ); 195} catch { 196existing = undefined ; 197} 198if ( existing === undefined ) result [ key ] = value ; 199} 200return result ; 201} 202 203function cacheDir ( root :string , explicit ?:string ) :string { 204if ( explicit ) return explicit ; 205const fromEnv = ( name :string ) :string | undefined => { 206try { 207return env . get ( name ); 208} catch { 209return undefined ; 210} 211}; 212const avrilCache = fromEnv ( "AVRIL_CACHE_DIR" ); 213const base = avrilCache ?? 214( fromEnv ( "XDG_CACHE_HOME" ) ?` ${ fromEnv ( "XDG_CACHE_HOME" )} /avril` :undefined ) ?? 215( fromEnv ( "HOME" ) ?` ${ fromEnv ( "HOME" )} /.cache/avril` :undefined ) ?? 216` ${ process . cwd ()} /.avril-cache` ; 217void root ; 218return ` ${ base } /task-cache` ; 219} 220 221interface RunState { 222root :string ; 223target :string ; 224options :RunOptions ; 225errors :Record < string , string >; 226} 227 228async function executeReady ( ready :TaskReadyJson , prepared :Prepared , state :RunState ) :Promise < number > { 229const { root, options} = state ; 230const action = ready . action ; 231if ( typeof action === "object" && "Callback" in action ) { 232const fn = prepared . functions . get ( action . Callback . id ); 233if ( ! fn ) return 1 ; 234let snapshot :Record < string , string > = {}; 235try { 236snapshot = { ...env . toObject () }; 237} catch { 238snapshot = {}; 239} 240try { 241const result = await fn ({ 242name :ready . name , 243 root, 244args :ready . args , 245env :{ ...ready . env , ...snapshot }, 246signal :options . signal , 247}); 248return typeof result === "number" ?result :0 ; 249} catch ( error ) { 250const message = error instanceof ErrorCtor ?error . message :String ( error ); 251state . errors [ ready . name ] = message ; 252options . onEvent ?.({ type :"error" , name :ready . name , message}); 253if ( ! options . onEvent ) op_console_print ([ true , `Task ${ ready . name } : ${ message } \n` ]); 254return 1 ; 255} 256} 257let program :string ; 258let argv :string []; 259if ( typeof action === "object" && "Shell" in action ) { 260const full = ready . args . length > 0 261 ?` ${ action . Shell } ${ ready . args . map ( shellQuote ). join ( " " )} ` 262 :action . Shell ; 263if ( runtime . os === "windows" ) { 264program = "cmd" ; 265argv = [ "/C" , full ]; 266} else { 267program = "sh" ; 268argv = [ "-c" , full ]; 269} 270} else if ( typeof action === "object" && "Argv" in action ) { 271program = action . Argv [ 0 ]; 272argv = [ ...action . Argv . slice ( 1 ), ...ready . args ]; 273} else { 274return 0 ; 275} 276const child = spawn ( program , argv , { 277cwd :root , 278env :environmentFor ( ready . env ), 279stdin :"inherit" , 280stdout :"inherit" , 281stderr :"inherit" , 282signal :options . signal as AbortSignal | undefined , 283}); 284const status = await child . status ; 285return status . code ?? 1 ; 286} 287 288function toReport ( json :TaskReportJson , errors :Record < string , string >) :TaskReport { 289const report :TaskReport = { 290code :json . code , 291ok :json . code === 0 , 292completed :json . completed , 293failed :json . failed , 294cached :json . cached , 295 errors, 296throwIfFailed () { 297if ( json . code !== 0 ) { 298const error = new ErrorCtor ( 299`Task run failed: ${ json . failed . map (( f ) => ` ${ f . name } ( ${ f . code } )` ). join ( ", " ) || "cancelled" } ` , 300) as TaskRunError ; 301Object . assign ( error , { name :"TaskRunError" , report}); 302throw error ; 303} 304}, 305}; 306return report ; 307} 308 309async function runOnce ( prepared :Prepared , state :RunState , force :boolean ) :Promise < TaskReport > { 310const { root, target, options} = state ; 311const session = op_task_begin ({ 312graph :prepared . graph , 313 root, 314 target, 315args :options . args ?? [], 316 force, 317cache_dir :cacheDir ( root , options . cacheDir ), 318}); 319try { 320for (;;) { 321const step = op_task_step ( session ); 322if ( "Done" in step ) { 323for ( const name of step . Done . cached ) { 324options . onEvent ?.({ type :"cached" , name}); 325} 326return toReport ( step . Done , state . errors ); 327} 328await promiseAll ( step . Ready . map ( async ( ready ) => { 329options . onEvent ?.({ type :"started" , name :ready . name }); 330const code = await executeReady ( ready , prepared , state ); 331op_task_finish ({ session, name :ready . name , code}); 332options . onEvent ?.({ type :"finished" , name :ready . name , code}); 333})); 334} 335} finally { 336op_task_drop ( session ); 337} 338} 339 340/** 341* Runs a task graph with avril's task semantics: dependencies first 342* (concurrently where a task sets `parallel`), each task at most once, 343* and result caching for tasks that declare `files`. Resolves with a 344* report even when tasks fail; graph errors throw. 345*/ 346export async function runTasks ( input :GraphInput , options :RunOptions = {}) :Promise < TaskReport > { 347const root = options . root ?? process . cwd (); 348const state :RunState = { 349 root, 350target :options . target ?? "all" , 351 options, 352errors :{}, 353}; 354 355let prepared = await prepare ( input ); 356let digest = op_task_digest ( prepared . graph ); 357let report = await runOnce ( prepared , state , options . force ?? false ); 358if ( ! options . watch ) return report ; 359 360const cacheRoot = cacheDir ( root , options . cacheDir ); 361const watcher = watchFs ( root , { recursive :true , signal :options . signal as AbortSignal | undefined }); 362let pendingPaths :{ path :string ; structural :boolean }[] = []; 363let timer :ReturnType < typeof setTimeout > | undefined ; 364let regenerating = false ; 365let dirty = false ; 366 367const regenerate = async () :Promise < void > => { 368if ( regenerating ) { 369dirty = true ; 370return ; 371} 372regenerating = true ; 373try { 374const paths = pendingPaths ; 375pendingPaths = []; 376let relevant = true ; 377try { 378relevant = op_task_relevant ({ 379graph :prepared . graph , 380target :state . target , 381 root, 382 paths, 383}); 384} catch { 385relevant = true ; 386} 387if ( relevant ) { 388prepared = await prepare ( input ); 389const nextDigest = op_task_digest ( prepared . graph ); 390const force = nextDigest !== digest ; 391digest = nextDigest ; 392report = await runOnce ( prepared , state , force ); 393} 394} catch ( error ) { 395const message = error instanceof ErrorCtor ?error . message :String ( error ); 396options . onEvent ?.({ type :"error" , name :state . target , message}); 397if ( ! options . onEvent ) op_console_print ([ true , `avril:task: ${ message } \n` ]); 398} finally { 399regenerating = false ; 400if ( dirty ) { 401dirty = false ; 402void regenerate (); 403} 404} 405}; 406 407const events = watcher as unknown as AsyncIterable <{ 408kind :string ; 409paths :readonly string []; 410}>; 411try { 412for await ( const event of events ) { 413const structural = event . kind === "create" || event . kind === "remove" || 414event . kind === "rename" || event . kind === "rescan" ; 415for ( const path of event . paths ) { 416const text = String ( path ); 417if ( text === cacheRoot || text . startsWith ( ` ${ cacheRoot } /` )) continue ; 418pendingPaths . push ({ path :text , structural}); 419} 420if ( timer !== undefined ) clearTimeout ( timer ); 421timer = setTimeout (() => { 422timer = undefined ; 423void regenerate (); 424}, 50 ); 425} 426} finally { 427if ( timer !== undefined ) clearTimeout ( timer ); 428try { 429watcher . close (); 430} catch { 431// Already closed by the abort signal. 432} 433} 434return report ; 435} 436