diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5512906 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +"on": + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check package + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24.x" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check package + run: bun run check + + - name: Check packed package + run: bun run pack:check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7edc651..3db7b44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: environment: name: npm permissions: - contents: write + contents: read id-token: write steps: @@ -166,10 +166,21 @@ jobs: PACKAGE_FILE: ${{ steps.pack.outputs.filename }} run: npm publish "$PACKAGE_FILE" --provenance --access public + github-release: + name: Create GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.repository == '0disoft/laqu' + needs: publish + permissions: + contents: write + + steps: - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} run: | gh release create "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ --title "${GITHUB_REF_NAME}" \ --generate-notes diff --git a/README.md b/README.md index 688217a..9a07e05 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,14 @@ build.setMessage("bundle"); build.advance(1); build.succeed("done"); +const optional = progress.createTask("optional cache warmup"); +optional.skip("already warm"); + await progress.close(); ``` The API avoids ambiguous calls such as `update(42)`. Use `setCompleted(42)` for absolute progress and `advance(42)` for a delta. -Manual tasks also honor `TaskOptions.signal`; aborting the signal marks the task cancelled with the message `aborted`. +Manual tasks also honor `TaskOptions.signal`; aborting the signal marks the task cancelled with the message `aborted`. Use `task.skip(message)` for intentionally skipped work such as cache hits, disabled feature branches, or already up-to-date steps. After `progress.close()` starts, the runtime stops accepting new tasks, logs, and task handle updates. Create a new runtime for later progress output. diff --git a/src/events.ts b/src/events.ts index d7d0506..3260da7 100644 --- a/src/events.ts +++ b/src/events.ts @@ -58,7 +58,7 @@ export function taskEvent(task: TaskSnapshot): LaquTaskEvent { schema: LAQU_EVENT_SCHEMA, version: LAQU_EVENT_SCHEMA_VERSION, type: "task", - createdAt: task.updatedAt, + createdAt: Date.now(), task: { id: task.id, title: task.title, diff --git a/src/runtime.ts b/src/runtime.ts index 56a2f35..8b36dab 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + import { OutputCoordinator } from "./output-coordinator.js"; import { chooseRenderer } from "./renderer.js"; import { @@ -81,6 +83,9 @@ class LaquRuntime implements ProgressRuntime { #closed = false; #processLifecycle: ProcessLifecycleLease | undefined; readonly #handles = new Set(); + readonly #taskCloseContext = new AsyncLocalStorage(); + #activeScopedTasks = 0; + #closeRequestedByScopedTask = false; constructor( private readonly store: TaskStore, @@ -106,9 +111,10 @@ class LaquRuntime implements ProgressRuntime { throw new TypeError("task callback is required"); } - const handle = this.createTask(title, options); + const handle = this.#createRootHandle(title, options); + this.#activeScopedTasks += 1; try { - const result = await callback(handle); + const result = await this.#taskCloseContext.run(handle, () => callback(handle)); if (this.#acceptsMutations()) { handle.succeed(); } @@ -125,17 +131,18 @@ class LaquRuntime implements ProgressRuntime { } throw error; } finally { + handle.dispose(); + this.#activeScopedTasks -= 1; await this.flush(); + if (this.#shouldRunDeferredScopedClose()) { + this.#closeRequestedByScopedTask = false; + await this.close(); + } } } createTask(title: string, options: TaskOptions = {}): TaskHandle { - this.#assertAcceptsMutations(); - const id = this.store.createTask(title, options); - const handle = this.#createHandle(id); - handle.bindSignal(options.signal); - this.markDirty(true); - return handle; + return this.#createRootHandle(title, options); } log(message: string): void { @@ -152,6 +159,11 @@ class LaquRuntime implements ProgressRuntime { } async close(): Promise { + if (this.#shouldDeferScopedClose()) { + this.#closeRequestedByScopedTask = true; + await this.flush(); + return; + } this.#closePromise ??= this.#closeOnce(); await this.#closePromise; } @@ -223,6 +235,24 @@ class LaquRuntime implements ProgressRuntime { }); } + #shouldDeferScopedClose(): boolean { + return ( + this.#taskCloseContext.getStore() !== undefined && + this.#activeScopedTasks > 0 && + !this.#closing && + !this.#closed + ); + } + + #shouldRunDeferredScopedClose(): boolean { + return ( + this.#closeRequestedByScopedTask && + this.#activeScopedTasks === 0 && + !this.#closing && + !this.#closed + ); + } + #acceptsMutations(): boolean { return !this.#closing && !this.#closed; } @@ -233,6 +263,15 @@ class LaquRuntime implements ProgressRuntime { } } + #createRootHandle(title: string, options: TaskOptions): StoreTaskHandle { + this.#assertAcceptsMutations(); + const id = this.store.createTask(title, options); + const handle = this.#createHandle(id); + handle.bindSignal(options.signal); + this.markDirty(true); + return handle; + } + #createHandle(id: string): StoreTaskHandle { let handle: StoreTaskHandle; handle = new StoreTaskHandle( @@ -420,6 +459,16 @@ class StoreTaskHandle implements TaskHandle { this.onChange(true); } + skip(message?: string): void { + this.assertWritable(); + this.store.update(this.id, { + status: "skipped", + ...(message === undefined ? {} : { message }), + }); + this.dispose(); + this.onChange(true); + } + child(title: string, options: TaskOptions = {}): TaskHandle { this.assertWritable(); return this.createChildHandle(this.id, title, options); diff --git a/src/types.ts b/src/types.ts index 22c3bf3..8cbc7a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,7 @@ export interface TaskHandle { succeed(message?: string): void; fail(error?: unknown): void; cancel(message?: string): void; + skip(message?: string): void; child(title: string, options?: TaskOptions): TaskHandle; } diff --git a/test/audit-followup.test.ts b/test/audit-followup.test.ts new file mode 100644 index 0000000..e67107c --- /dev/null +++ b/test/audit-followup.test.ts @@ -0,0 +1,195 @@ +import { deepStrictEqual, rejects, strictEqual } from "node:assert"; +import test from "node:test"; + +import { createLaqu } from "../src/index.js"; +import type { LaquEvent, LaquTaskEvent } from "../src/events.js"; +import type { StreamTarget } from "../src/types.js"; + +class FakeStream implements StreamTarget { + readonly chunks: string[] = []; + isTTY = false; + columns = 80; + + write(chunk: string): boolean { + this.chunks.push(chunk); + return true; + } + + text(): string { + return this.chunks.join(""); + } +} + +test("scoped close called inside the callback defers final summary until the task resolves", async () => { + const stderr = new FakeStream(); + const runtime = createLaqu({ stderr, env: {}, format: "json", streamCapability: "pipe" }); + + const result = await runtime.task("close during scope", async () => { + await runtime.close(); + return 9; + }); + + const events = JSON.parse(stderr.text()) as LaquEvent[]; + const summary = events.find((event) => event.type === "summary"); + const taskEvents = events.filter( + (event): event is LaquTaskEvent => + event.type === "task" && event.task.title === "close during scope", + ); + + strictEqual(result, 9); + strictEqual(taskEvents.at(-1)?.task.status, "succeeded"); + deepStrictEqual(summary?.tasks, { + total: 1, + running: 0, + succeeded: 1, + failed: 0, + cancelled: 0, + skipped: 0, + }); +}); + +test("scoped task failure disposes the task handle and abort listener", async () => { + const stderr = new FakeStream(); + const runtime = createLaqu({ stderr, env: {}, format: "json", streamCapability: "pipe" }); + const controller = new AbortController(); + const signal = controller.signal; + const addEventListener = signal.addEventListener.bind(signal); + const removeEventListener = signal.removeEventListener.bind(signal); + let activeAbortListeners = 0; + + signal.addEventListener = ((type, listener, options) => { + if (type === "abort") { + activeAbortListeners += 1; + } + addEventListener(type, listener, options); + }) as AbortSignal["addEventListener"]; + signal.removeEventListener = ((type, listener, options) => { + if (type === "abort") { + activeAbortListeners -= 1; + } + removeEventListener(type, listener, options); + }) as AbortSignal["removeEventListener"]; + + await rejects( + runtime.task("throws during scope", { signal }, async () => { + throw new Error("boom"); + }), + /boom/, + ); + + strictEqual(activeAbortListeners, 0); + await runtime.close(); + + const events = JSON.parse(stderr.text()) as LaquEvent[]; + const summary = events.find((event) => event.type === "summary"); + const taskEvents = events.filter( + (event): event is LaquTaskEvent => + event.type === "task" && event.task.title === "throws during scope", + ); + + strictEqual(taskEvents.at(-1)?.task.status, "failed"); + strictEqual(taskEvents.at(-1)?.task.message, "boom"); + deepStrictEqual(summary?.tasks, { + total: 1, + running: 0, + succeeded: 0, + failed: 1, + cancelled: 0, + skipped: 0, + }); +}); + +test("scoped close still defers after the callback manually completes the task", async () => { + const stderr = new FakeStream(); + const runtime = createLaqu({ stderr, env: {}, format: "json", streamCapability: "pipe" }); + + const result = await runtime.task("manual complete before close", async (task) => { + task.succeed("done"); + await runtime.close(); + runtime.log("callback still owns the runtime"); + return 12; + }); + + const events = JSON.parse(stderr.text()) as LaquEvent[]; + const summary = events.find((event) => event.type === "summary"); + const taskEvents = events.filter( + (event): event is LaquTaskEvent => + event.type === "task" && event.task.title === "manual complete before close", + ); + + strictEqual(result, 12); + strictEqual( + events.some((event) => event.type === "log"), + true, + ); + strictEqual(taskEvents.at(-1)?.task.status, "succeeded"); + strictEqual(taskEvents.at(-1)?.task.message, "done"); + deepStrictEqual(summary?.tasks, { + total: 1, + running: 0, + succeeded: 1, + failed: 0, + cancelled: 0, + skipped: 0, + }); +}); + +test("parent aggregate task event timestamp advances when a child changes progress", async () => { + const originalNow = Date.now; + let now = 1_000; + Date.now = () => now; + try { + const stderr = new FakeStream(); + const runtime = createLaqu({ stderr, env: {}, format: "ndjson", streamCapability: "pipe" }); + const parent = runtime.createTask("parent"); + const child = parent.child("child", { ratio: 0 }); + await runtime.flush(); + + now = 2_000; + child.setRatio(1); + await runtime.flush(); + await runtime.close(); + + const parentEvents = stderr + .text() + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as LaquEvent) + .filter( + (event): event is LaquTaskEvent => event.type === "task" && event.task.id === parent.id, + ); + + strictEqual(parentEvents.length >= 2, true); + strictEqual(parentEvents.at(0)?.createdAt, 1_000); + strictEqual(parentEvents.at(-1)?.createdAt, 2_000); + } finally { + Date.now = originalNow; + } +}); + +test("public skip API emits skipped task status and summary count", async () => { + const stderr = new FakeStream(); + const runtime = createLaqu({ stderr, env: {}, format: "json", streamCapability: "pipe" }); + const task = runtime.createTask("cache warmup"); + + task.skip("already warm"); + await runtime.close(); + + const events = JSON.parse(stderr.text()) as LaquEvent[]; + const taskEvents = events.filter( + (event): event is LaquTaskEvent => event.type === "task" && event.task.title === "cache warmup", + ); + const summary = events.find((event) => event.type === "summary"); + + strictEqual(taskEvents.at(-1)?.task.status, "skipped"); + strictEqual(taskEvents.at(-1)?.task.message, "already warm"); + deepStrictEqual(summary?.tasks, { + total: 1, + running: 0, + succeeded: 0, + failed: 0, + cancelled: 0, + skipped: 1, + }); +});