char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somextract avril-task crate + add avril:task module7f4058a

main
3.7 KiB82 linesraw

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 an avril.conf task 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({
15  page: {
16    files: ["content/page.md", "render.ts"],
17    output: ["dist/page.html"],
18    run: async (ctx) => { /* render the page */ },
19  },
20  assets: {
21    command: ["avril", "bundle", "web/main.tsx", "--outdir", "dist"],
22    files: ["web/**"],
23    output: ["dist/main.js"],
24  },
25  all: { 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`),
36  like an `avril.conf` task string.
37- `command: string[]` — spawn directly, without a shell. Arguments keep
38  their boundaries; shell metacharacters are literal.
39- `run: (ctx) => void | number | Promise<void | number>` — call a function
40  in this process. It receives `{ name, root, args, env, signal }`; a
41  returned 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.