char/avril

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

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

Charlotte Somgenerate task typedefs from typescript impl1069809

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