import { Error as ErrorCtor, Map, Object, String, TypeError, } from "ext:core/primordials"; import { promiseAll } from "ext:core/promises"; import { op_console_print, op_task_begin, op_task_digest, op_task_drop, op_task_finish, op_task_relevant, op_task_step, } from "ext:core/ops"; import type { TaskActionJson, TaskGraphJson, TaskReadyJson, TaskReportJson, } from "ext:core/ops"; import { spawn } from "ext:process/main.js"; import { watchFs } from "ext:file/main.js"; import type { AbortSignal } from "ext:web/events.js"; import { env } from "ext:base/env.js"; import { process, runtime } from "ext:base/host.js"; import { clearTimeout, setTimeout } from "ext:timers/timers.js"; /** The subset of `AbortSignal` that `avril:task` relies on; a real `AbortSignal` satisfies it. */ export interface AbortSignalLike { readonly aborted: boolean; } /** The context handed to a function task when it runs. */ export interface TaskContext { readonly name: string; /** The directory task paths are relative to. */ readonly root: string; /** Extra arguments; non-empty only for the run's target. */ readonly args: readonly string[]; /** The task's effective environment: file defaults under the process environment. */ readonly env: Record; readonly signal?: AbortSignalLike; } export interface TaskDef { description?: string; /** A shell string (`sh -c` / `cmd /C`), or an argv array spawned without a shell. */ command?: string | string[]; /** An in-process action. Return a number to use it as the exit code. */ run?: (ctx: TaskContext) => void | number | Promise; dependencies?: string[]; parallel?: boolean; envFile?: string; /** Declaring `files` opts the task into result caching. */ files?: string | string[]; output?: string | string[]; env?: string | string[]; } export interface FailedTaskInfo { name: string; code: number; } export interface TaskReport { readonly code: number; readonly ok: boolean; readonly completed: string[]; readonly failed: FailedTaskInfo[]; readonly cached: string[]; /** Messages from function tasks that threw, by task name. */ readonly errors: Record; /** Throws a `TaskRunError` carrying this report when the run failed. */ throwIfFailed(): void; } /** The error thrown by `TaskReport.throwIfFailed()`. */ export interface TaskRunError extends Error { readonly name: "TaskRunError"; readonly report: TaskReport; } export type TaskEvent = | { type: "started"; name: string } | { type: "cached"; name: string } | { type: "finished"; name: string; code: number } | { type: "error"; name: string; message: string }; export interface RunOptions { /** The task to run. Defaults to `"all"`. */ target?: string; /** Directory task paths are relative to. Defaults to the working directory. */ root?: string; /** Extra arguments forwarded to the target. */ args?: string[]; /** Run cacheable tasks even when their outputs are intact. */ force?: boolean; /** Task cache directory. Defaults to avril's shared task cache. */ cacheDir?: string; /** Rerun when task inputs change, re-evaluating the graph each time. */ watch?: boolean; signal?: AbortSignalLike; onEvent?: (event: TaskEvent) => void; } export type GraphInput = | Record | (() => Record | Promise>); type TaskFn = (ctx: TaskContext) => void | number | Promise; function fail(path: string, message: string): never { throw new TypeError(`avril:task: ${path}: ${message}`); } function list(value: string | string[] | undefined, path: string): string[] | undefined { if (value === undefined) return undefined; const items = typeof value === "string" ? [value] : value; if (!Array.isArray(items) || items.some((item) => typeof item !== "string" || item === "")) { fail(path, "expected a string or an array of non-empty strings"); } return [...items]; } interface Prepared { graph: TaskGraphJson; functions: Map; } async function prepare(input: GraphInput): Promise { const definitions = typeof input === "function" ? await input() : input; if (typeof definitions !== "object" || definitions === null || Array.isArray(definitions)) { fail("graph", "expected an object mapping task names to tasks"); } const functions = new Map(); const tasks: TaskGraphJson["tasks"] = {}; let nextId = 0; for (const [name, definition] of Object.entries(definitions)) { const path = `tasks.${name}`; if (typeof definition !== "object" || definition === null) { fail(path, "expected a task object"); } const hasCommand = definition.command !== undefined; const hasRun = definition.run !== undefined; if (hasCommand && hasRun) { fail(path, "use either command or run, not both"); } let action: TaskActionJson; if (!hasCommand && !hasRun) { action = "None"; } else if (hasRun) { if (typeof definition.run !== "function") fail(`${path}.run`, "expected a function"); const id = nextId++; functions.set(id, definition.run); // The function source is its fingerprint identity: editing the body // invalidates the task; editing its imports invalidates only via `files`. action = { Callback: { id, key: String(definition.run) } }; } else if (typeof definition.command === "string") { if (definition.command.trim() === "") fail(`${path}.command`, "command cannot be empty"); action = { Shell: definition.command }; } else { const argv = list(definition.command, `${path}.command`); if (!argv || argv.length === 0) fail(`${path}.command`, "command array cannot be empty"); action = { Argv: argv }; } tasks[name] = { description: definition.description ?? null, action, dependencies: list(definition.dependencies, `${path}.dependencies`) ?? [], parallel: definition.parallel ?? false, env_file: definition.envFile ?? null, files: list(definition.files, `${path}.files`) ?? null, output: list(definition.output, `${path}.output`) ?? [], env: list(definition.env, `${path}.env`) ?? [], }; } return { graph: { tasks }, functions }; } function shellQuote(arg: string): string { if (runtime.os === "windows") return `"${arg.replaceAll('"', '\\"')}"`; if (/^[A-Za-z0-9._\-/:@%+=,]+$/.test(arg)) return arg; return `'${arg.replaceAll("'", "'\\''")}'`; } function environmentFor(defaults: Record): Record { const result: Record = {}; for (const [key, value] of Object.entries(defaults)) { let existing: string | undefined; try { existing = env.get(key); } catch { existing = undefined; } if (existing === undefined) result[key] = value; } return result; } function cacheDir(root: string, explicit?: string): string { if (explicit) return explicit; const fromEnv = (name: string): string | undefined => { try { return env.get(name); } catch { return undefined; } }; const avrilCache = fromEnv("AVRIL_CACHE_DIR"); const base = avrilCache ?? (fromEnv("XDG_CACHE_HOME") ? `${fromEnv("XDG_CACHE_HOME")}/avril` : undefined) ?? (fromEnv("HOME") ? `${fromEnv("HOME")}/.cache/avril` : undefined) ?? `${process.cwd()}/.avril-cache`; void root; return `${base}/task-cache`; } interface RunState { root: string; target: string; options: RunOptions; errors: Record; } async function executeReady(ready: TaskReadyJson, prepared: Prepared, state: RunState): Promise { const { root, options } = state; const action = ready.action; if (typeof action === "object" && "Callback" in action) { const fn = prepared.functions.get(action.Callback.id); if (!fn) return 1; let snapshot: Record = {}; try { snapshot = { ...env.toObject() }; } catch { snapshot = {}; } try { const result = await fn({ name: ready.name, root, args: ready.args, env: { ...ready.env, ...snapshot }, signal: options.signal, }); return typeof result === "number" ? result : 0; } catch (error) { const message = error instanceof ErrorCtor ? error.message : String(error); state.errors[ready.name] = message; options.onEvent?.({ type: "error", name: ready.name, message }); if (!options.onEvent) op_console_print([true, `Task ${ready.name}: ${message}\n`]); return 1; } } let program: string; let argv: string[]; if (typeof action === "object" && "Shell" in action) { const full = ready.args.length > 0 ? `${action.Shell} ${ready.args.map(shellQuote).join(" ")}` : action.Shell; if (runtime.os === "windows") { program = "cmd"; argv = ["/C", full]; } else { program = "sh"; argv = ["-c", full]; } } else if (typeof action === "object" && "Argv" in action) { program = action.Argv[0]; argv = [...action.Argv.slice(1), ...ready.args]; } else { return 0; } const child = spawn(program, argv, { cwd: root, env: environmentFor(ready.env), stdin: "inherit", stdout: "inherit", stderr: "inherit", signal: options.signal as AbortSignal | undefined, }); const status = await child.status; return status.code ?? 1; } function toReport(json: TaskReportJson, errors: Record): TaskReport { const report: TaskReport = { code: json.code, ok: json.code === 0, completed: json.completed, failed: json.failed, cached: json.cached, errors, throwIfFailed() { if (json.code !== 0) { const error = new ErrorCtor( `Task run failed: ${json.failed.map((f) => `${f.name} (${f.code})`).join(", ") || "cancelled"}`, ) as TaskRunError; Object.assign(error, { name: "TaskRunError", report }); throw error; } }, }; return report; } async function runOnce(prepared: Prepared, state: RunState, force: boolean): Promise { const { root, target, options } = state; const session = op_task_begin({ graph: prepared.graph, root, target, args: options.args ?? [], force, cache_dir: cacheDir(root, options.cacheDir), }); try { for (;;) { const step = op_task_step(session); if ("Done" in step) { for (const name of step.Done.cached) { options.onEvent?.({ type: "cached", name }); } return toReport(step.Done, state.errors); } await promiseAll(step.Ready.map(async (ready) => { options.onEvent?.({ type: "started", name: ready.name }); const code = await executeReady(ready, prepared, state); op_task_finish({ session, name: ready.name, code }); options.onEvent?.({ type: "finished", name: ready.name, code }); })); } } finally { op_task_drop(session); } } /** * Runs a task graph with avril's task semantics: dependencies first * (concurrently where a task sets `parallel`), each task at most once, * and result caching for tasks that declare `files`. Resolves with a * report even when tasks fail; graph errors throw. */ export async function runTasks(input: GraphInput, options: RunOptions = {}): Promise { const root = options.root ?? process.cwd(); const state: RunState = { root, target: options.target ?? "all", options, errors: {}, }; let prepared = await prepare(input); let digest = op_task_digest(prepared.graph); let report = await runOnce(prepared, state, options.force ?? false); if (!options.watch) return report; const cacheRoot = cacheDir(root, options.cacheDir); const watcher = watchFs(root, { recursive: true, signal: options.signal as AbortSignal | undefined }); let pendingPaths: { path: string; structural: boolean }[] = []; let timer: ReturnType | undefined; let regenerating = false; let dirty = false; const regenerate = async (): Promise => { if (regenerating) { dirty = true; return; } regenerating = true; try { const paths = pendingPaths; pendingPaths = []; let relevant = true; try { relevant = op_task_relevant({ graph: prepared.graph, target: state.target, root, paths, }); } catch { relevant = true; } if (relevant) { prepared = await prepare(input); const nextDigest = op_task_digest(prepared.graph); const force = nextDigest !== digest; digest = nextDigest; report = await runOnce(prepared, state, force); } } catch (error) { const message = error instanceof ErrorCtor ? error.message : String(error); options.onEvent?.({ type: "error", name: state.target, message }); if (!options.onEvent) op_console_print([true, `avril:task: ${message}\n`]); } finally { regenerating = false; if (dirty) { dirty = false; void regenerate(); } } }; const events = watcher as unknown as AsyncIterable<{ kind: string; paths: readonly string[]; }>; try { for await (const event of events) { const structural = event.kind === "create" || event.kind === "remove" || event.kind === "rename" || event.kind === "rescan"; for (const path of event.paths) { const text = String(path); if (text === cacheRoot || text.startsWith(`${cacheRoot}/`)) continue; pendingPaths.push({ path: text, structural }); } if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => { timer = undefined; void regenerate(); }, 50); } } finally { if (timer !== undefined) clearTimeout(timer); try { watcher.close(); } catch { // Already closed by the abort signal. } } return report; }