From 7092b95f3800d642544607eab148e3fd700a9d0e Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Thu, 9 Jul 2026 22:16:03 +0900 Subject: [PATCH 1/8] fix: defer scoped close and expose skip --- src/runtime.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/src/runtime.ts b/src/runtime.ts index 56a2f35..632c8e5 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,17 @@ class LaquRuntime implements ProgressRuntime { } throw error; } finally { + 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 +158,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 +234,26 @@ class LaquRuntime implements ProgressRuntime { }); } + #shouldDeferScopedClose(): boolean { + const activeHandle = this.#taskCloseContext.getStore(); + return ( + activeHandle !== undefined && + this.#handles.has(activeHandle) && + 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 +264,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 +460,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); From e915a821dab6516308f127bf2bf289b43f9b96d9 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Thu, 9 Jul 2026 22:16:33 +0900 Subject: [PATCH 2/8] fix: expose skipped task handle API --- src/types.ts | 1 + 1 file changed, 1 insertion(+) 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; } From b0cacdcd839166f2c445d7a1181515965d53b3d3 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Thu, 9 Jul 2026 22:16:45 +0900 Subject: [PATCH 3/8] fix: timestamp task events at emission time --- src/events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 652bccb9d03b5928bb39b7419a226c44f25879d3 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Thu, 9 Jul 2026 22:17:03 +0900 Subject: [PATCH 4/8] test: cover follow-up audit findings --- test/audit-followup.test.ts | 109 ++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 test/audit-followup.test.ts diff --git a/test/audit-followup.test.ts b/test/audit-followup.test.ts new file mode 100644 index 0000000..0991abb --- /dev/null +++ b/test/audit-followup.test.ts @@ -0,0 +1,109 @@ +import { deepStrictEqual, 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("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, + }); +}); From 44fffc660238899512e68677005eac089373c7a6 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Thu, 9 Jul 2026 22:17:13 +0900 Subject: [PATCH 5/8] ci: verify pushes and pull requests --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci.yml 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 From 203d56ba252b06586fd6dd6793ae148fc2066ce8 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Thu, 9 Jul 2026 22:17:31 +0900 Subject: [PATCH 6/8] ci: split release publish permissions --- .github/workflows/release.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7edc651..27c934d 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,6 +166,16 @@ 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 }} From 2789aed187dd862eb287b69cb1a27e7495e48ba4 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Thu, 9 Jul 2026 22:18:02 +0900 Subject: [PATCH 7/8] docs: document skipped manual tasks --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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. From 815ae7b239f8038257d8a0f0162975f4f71ebbc6 Mon Sep 17 00:00:00 2001 From: 0disoft Date: Thu, 9 Jul 2026 22:35:13 +0900 Subject: [PATCH 8/8] fix(runtime): harden scoped close cleanup --- .github/workflows/release.yml | 1 + src/runtime.ts | 5 +- test/audit-followup.test.ts | 88 ++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27c934d..3db7b44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,5 +181,6 @@ jobs: GH_TOKEN: ${{ github.token }} run: | gh release create "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ --title "${GITHUB_REF_NAME}" \ --generate-notes diff --git a/src/runtime.ts b/src/runtime.ts index 632c8e5..8b36dab 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -131,6 +131,7 @@ class LaquRuntime implements ProgressRuntime { } throw error; } finally { + handle.dispose(); this.#activeScopedTasks -= 1; await this.flush(); if (this.#shouldRunDeferredScopedClose()) { @@ -235,10 +236,8 @@ class LaquRuntime implements ProgressRuntime { } #shouldDeferScopedClose(): boolean { - const activeHandle = this.#taskCloseContext.getStore(); return ( - activeHandle !== undefined && - this.#handles.has(activeHandle) && + this.#taskCloseContext.getStore() !== undefined && this.#activeScopedTasks > 0 && !this.#closing && !this.#closed diff --git a/test/audit-followup.test.ts b/test/audit-followup.test.ts index 0991abb..e67107c 100644 --- a/test/audit-followup.test.ts +++ b/test/audit-followup.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual, strictEqual } from "node:assert"; +import { deepStrictEqual, rejects, strictEqual } from "node:assert"; import test from "node:test"; import { createLaqu } from "../src/index.js"; @@ -48,6 +48,92 @@ test("scoped close called inside the callback defers final summary until the tas }); }); +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;