char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
7f4058a
main
avril:task
avril:task runs a task graph from guest code, with the same semantics as
avril task: dependencies first (concurrently where a
task sets parallel), each task at most once per run, and result caching
for tasks that declare files. Unlike avril task, the graph is a
TypeScript value, so it can be computed — enumerated from the filesystem,
derived from content, built by loops — and tasks can run functions
in-process instead of spawning a program per task.
import { runTasks } from "avril:task" ; const report = await runTasks ({ page :{ files :[ "content/page.md" , "render.ts" ], output :[ "dist/page.html" ], run :async ( ctx ) => { /* render the page */ }, }, assets :{ command :[ "avril" , "bundle" , "web/main.tsx" , "--outdir" , "dist" ], files :[ "web/**" ], output :[ "dist/main.js" ], }, all :{ dependencies :[ "page" , "assets" ], parallel :true }, }, { target :"all" }); report . throwIfFailed ();
Tasks
A task has exactly one action, or none (a pure grouping of dependencies):
command: string— run through the platform shell (sh -c/cmd /C), like anavril.conftask string.command: string[]— spawn directly, without a shell. Arguments keep their boundaries; shell metacharacters are literal.run: (ctx) => void | number | Promise<void | number>— call a function in this process. It receives{ name, root, args, env, signal }; a returned number is its exit code, a throw is a failure with code 1.
dependencies names other tasks in the graph, which run first; a failed
dependency skips the dependent's action. files, output, env, and
envFile behave as in avril.conf: declaring files opts into caching,
keyed on the matched files, the named env values, the action, and every
dependency's fingerprint. A task is skipped while its inputs are unchanged
and its outputs are present and untouched. Cache entries live in avril's
shared task cache, so the same graph hits the same entries whether it was
run through avril:task or expressed in avril.conf.
One caveat for function tasks: the fingerprint includes the function's
source text, so editing its body invalidates it — but code it imports is
not traced. List shared modules in files (as render.ts is above), or
their edits will not invalidate cached outputs. The same is true of shell
tasks, whose command string is fingerprinted in place of the program it
runs.
Running
runTasks(graph | (() => graph), options?) accepts a graph or a thunk
returning one (possibly asynchronously), and resolves with a report —
{ code, ok, completed, failed, cached, errors } — even when tasks fail.
Only graph errors (unknown tasks, cycles, unmatched files patterns)
throw. report.throwIfFailed() throws a TaskRunError carrying the
report, which is how an entry point turns a failed build into a nonzero
exit.
Options: target (default "all"), root (default the working
directory), args (forwarded to the target only), force (ignore cache
hits), cacheDir, onEvent (started / cached / finished / error),
and signal.
With watch: true, the run repeats when task inputs change: events are
debounced over 50ms, the graph thunk is re-evaluated each cycle (so files
created since the last cycle can appear as new tasks), and a changed graph
definition bypasses the cache for that cycle, mirroring how
avril task --watch treats config edits. A failed cycle is reported like
any other and watching continues; aborting signal ends the watch and
resolves with the last report. Editing the module that defines the graph
is not picked up — its imports stay cached in this process — so restart
the program after editing the build definition itself.
1# `avril:task` 2 3`avril:task` runs a task graph from guest code, with the same semantics as 4[`avril task`](config.md#tasks): dependencies first (concurrently where a 5task sets `parallel`), each task at most once per run, and result caching 6for tasks that declare `files`. Unlike `avril task`, the graph is a 7TypeScript value, so it can be computed — enumerated from the filesystem, 8derived from content, built by loops — and tasks can run functions 9in-process instead of spawning a program per task. 10 11``` ts 12import { runTasks } from "avril:task"; 13 14const report = await runTasks({ 15page: { 16files: ["content/page.md", "render.ts"], 17output: ["dist/page.html"], 18run: async (ctx) => { /* render the page */ }, 19}, 20assets: { 21command: ["avril", "bundle", "web/main.tsx", "--outdir", "dist"], 22files: ["web/**"], 23output: ["dist/main.js"], 24}, 25all: { dependencies: ["page", "assets"], parallel: true }, 26}, { target: "all" }); 27 28report.throwIfFailed(); 29``` 30 31## Tasks 32 33A task has exactly one action, or none (a pure grouping of dependencies): 34 35- `command: string` — run through the platform shell (`sh -c` / `cmd /C`), 36like an `avril.conf` task string. 37- `command: string[]` — spawn directly, without a shell. Arguments keep 38their boundaries; shell metacharacters are literal. 39- `run: (ctx) => void | number | Promise<void | number>` — call a function 40in this process. It receives `{ name, root, args, env, signal }`; a 41returned number is its exit code, a throw is a failure with code 1. 42 43`dependencies` names other tasks in the graph, which run first; a failed 44dependency skips the dependent's action. `files`, `output`, `env`, and 45`envFile` behave as in `avril.conf`: declaring `files` opts into caching, 46keyed on the matched files, the named env values, the action, and every 47dependency's fingerprint. A task is skipped while its inputs are unchanged 48and its outputs are present and untouched. Cache entries live in avril's 49shared task cache, so the same graph hits the same entries whether it was 50run through `avril:task` or expressed in `avril.conf`. 51 52One caveat for function tasks: the fingerprint includes the function's 53source text, so editing its body invalidates it — but code it *imports* is 54not traced. List shared modules in `files` (as `render.ts` is above), or 55their edits will not invalidate cached outputs. The same is true of shell 56tasks, whose command string is fingerprinted in place of the program it 57runs. 58 59## Running 60 61`runTasks(graph | (() => graph), options?)` accepts a graph or a thunk 62returning one (possibly asynchronously), and resolves with a report — 63`{ code, ok, completed, failed, cached, errors }` — even when tasks fail. 64Only graph errors (unknown tasks, cycles, unmatched `files` patterns) 65throw. `report.throwIfFailed()` throws a `TaskRunError` carrying the 66report, which is how an entry point turns a failed build into a nonzero 67exit. 68 69Options: `target` (default `"all"`), `root` (default the working 70directory), `args` (forwarded to the target only), `force` (ignore cache 71hits), `cacheDir`, `onEvent` (`started` / `cached` / `finished` / `error`), 72and `signal`. 73 74With `watch: true`, the run repeats when task inputs change: events are 75debounced over 50ms, the graph thunk is re-evaluated each cycle (so files 76created since the last cycle can appear as new tasks), and a changed graph 77definition bypasses the cache for that cycle, mirroring how 78`avril task --watch` treats config edits. A failed cycle is reported like 79any other and watching continues; aborting `signal` ends the watch and 80resolves with the last report. Editing the module that *defines* the graph 81is not picked up — its imports stay cached in this process — so restart 82the program after editing the build definition itself.