From bd371eec148afab9b0444ce5031d76a30e6136be Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Mon, 27 Jul 2026 00:46:55 +0000 Subject: [PATCH] perf(coding-agent): load changelog asset on demand --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/package.json | 1 + packages/coding-agent/scripts/bundle-dist.ts | 12 +- packages/coding-agent/src/utils/changelog.ts | 11 +- .../changelog-bundle-fallback-probe.ts | 16 ++ .../changelog-static-import-heap-probe.ts | 37 +++++ .../test/fixtures/changelog-utils-stub.ts | 8 + .../utils/changelog-static-import.test.ts | 155 ++++++++++++++++++ .../coding-agent/test/utils/changelog.test.ts | 8 +- 9 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 packages/coding-agent/test/fixtures/changelog-bundle-fallback-probe.ts create mode 100644 packages/coding-agent/test/fixtures/changelog-static-import-heap-probe.ts create mode 100644 packages/coding-agent/test/fixtures/changelog-utils-stub.ts create mode 100644 packages/coding-agent/test/utils/changelog-static-import.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8286d275b..80a6accc7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Reduced startup CPU and memory by loading the bundled changelog only when needed, while preserving source, npm bundle, standalone binary, and native absolute-path fallback resolution. + ## [17.1.4] - 2026-07-26 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index ff13962b8..66326d54b 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -106,6 +106,7 @@ "files": [ "src", "dist/cli.js", + "dist/CHANGELOG-*.md", "dist/*.node", "scripts", "examples", diff --git a/packages/coding-agent/scripts/bundle-dist.ts b/packages/coding-agent/scripts/bundle-dist.ts index 98534d251..3d54530ee 100755 --- a/packages/coding-agent/scripts/bundle-dist.ts +++ b/packages/coding-agent/scripts/bundle-dist.ts @@ -64,8 +64,8 @@ function formatBytes(bytes: number): string { } async function cleanBundleOutputs(): Promise { - // dist/ is shared with the dev binary (dist/omp); only remove this - // script's own outputs (entry bundle + copied native assets). + // dist/ is shared with the dev binary (dist/omp); only remove assets + // emitted by this script. let entries: string[]; try { entries = await fs.readdir(outDir); @@ -75,7 +75,13 @@ async function cleanBundleOutputs(): Promise { } await Promise.all( entries - .filter(entry => entry === "cli.js" || entry.endsWith(".node") || entry.endsWith(".js.map")) + .filter( + entry => + entry === "cli.js" || + entry.endsWith(".node") || + entry.endsWith(".js.map") || + (entry.startsWith("CHANGELOG-") && entry.endsWith(".md")), + ) .map(entry => fs.rm(path.join(outDir, entry), { force: true })), ); } diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index 182cc5581..b018fda00 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -1,5 +1,6 @@ +import * as path from "node:path"; import { getLastChangelogVersionPath, isEnoent, logger } from "@oh-my-pi/pi-utils"; -import bundledChangelog from "../../CHANGELOG.md" with { type: "text" }; +import bundledChangelogPath from "../../CHANGELOG.md" with { type: "file" }; export interface ChangelogEntry { major: number; @@ -36,8 +37,13 @@ export interface StartupChangelogSelection { * The embedded fallback keeps standalone binaries self-contained without * resolving relative to the host project's cwd, which caused issue #1423. */ +export function resolveBundledChangelogPath(assetPath: string, moduleUrl: string | URL): string | URL { + if (path.isAbsolute(assetPath) || path.win32.isAbsolute(assetPath)) return assetPath; + return new URL(assetPath, moduleUrl); +} + export async function parseChangelog(changelogPath: string | undefined): Promise { - let content = bundledChangelog; + let content: string | undefined; if (changelogPath) { try { content = await Bun.file(changelogPath).text(); @@ -47,6 +53,7 @@ export async function parseChangelog(changelogPath: string | undefined): Promise } } } + content ??= await Bun.file(resolveBundledChangelogPath(bundledChangelogPath, import.meta.url)).text(); return parseChangelogContent(content); } diff --git a/packages/coding-agent/test/fixtures/changelog-bundle-fallback-probe.ts b/packages/coding-agent/test/fixtures/changelog-bundle-fallback-probe.ts new file mode 100644 index 000000000..bd7cdd408 --- /dev/null +++ b/packages/coding-agent/test/fixtures/changelog-bundle-fallback-probe.ts @@ -0,0 +1,16 @@ +import { VERSION } from "@oh-my-pi/pi-utils"; +import { parseChangelog } from "../../src/utils/changelog"; + +const missingPackageChangelogPath = process.argv[2]; +if (!missingPackageChangelogPath) { + throw new Error("Expected a missing package changelog path argument"); +} + +const entries = await parseChangelog(missingPackageChangelogPath); +const latest = entries[0]; +const version = latest ? `${latest.major}.${latest.minor}.${latest.patch}` : undefined; +if (version !== VERSION || !latest?.content.startsWith(`## [${VERSION}]`)) { + throw new Error(`Unexpected latest changelog release: ${JSON.stringify({ version, expected: VERSION })}`); +} + +process.stdout.write(JSON.stringify({ version, entries: entries.length })); diff --git a/packages/coding-agent/test/fixtures/changelog-static-import-heap-probe.ts b/packages/coding-agent/test/fixtures/changelog-static-import-heap-probe.ts new file mode 100644 index 000000000..c7a4eafe6 --- /dev/null +++ b/packages/coding-agent/test/fixtures/changelog-static-import-heap-probe.ts @@ -0,0 +1,37 @@ +import "../../src/utils/changelog"; + +interface V8HeapSnapshot { + snapshot: { + meta: { + node_fields: string[]; + node_types: Array; + }; + }; + nodes: number[]; + strings: string[]; +} + +const LARGE_STRING_MIN_SIZE = 1024 * 1024; + +Bun.gc(true); +const heap = JSON.parse(Bun.generateHeapSnapshot("v8")) as V8HeapSnapshot; +const { node_fields: nodeFields, node_types: nodeTypes } = heap.snapshot.meta; +const typeOffset = nodeFields.indexOf("type"); +const nameOffset = nodeFields.indexOf("name"); +const sizeOffset = nodeFields.indexOf("self_size"); +const typeNames = nodeTypes[typeOffset]; +if (typeOffset < 0 || nameOffset < 0 || sizeOffset < 0 || !Array.isArray(typeNames)) { + throw new Error("Unexpected V8 heap snapshot schema"); +} + +let retainedChangelogStrings = 0; +for (let nodeOffset = 0; nodeOffset < heap.nodes.length; nodeOffset += nodeFields.length) { + if (typeNames[heap.nodes[nodeOffset + typeOffset]!] !== "string") continue; + const value = heap.strings[heap.nodes[nodeOffset + nameOffset]!]; + const selfSize = heap.nodes[nodeOffset + sizeOffset]!; + if (value !== undefined && selfSize > LARGE_STRING_MIN_SIZE && value.startsWith("# Changelog")) { + retainedChangelogStrings++; + } +} + +process.stdout.write(JSON.stringify({ retainedChangelogStrings })); diff --git a/packages/coding-agent/test/fixtures/changelog-utils-stub.ts b/packages/coding-agent/test/fixtures/changelog-utils-stub.ts new file mode 100644 index 000000000..483277686 --- /dev/null +++ b/packages/coding-agent/test/fixtures/changelog-utils-stub.ts @@ -0,0 +1,8 @@ +import packageJson from "../../package.json" with { type: "json" }; + +export const VERSION = packageJson.version; +export const getLastChangelogVersionPath = (): string => ""; +export const getChangelogPath = (): string | undefined => undefined; +export const isEnoent = (error: unknown): boolean => + typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +export const logger = { error: () => {}, warn: () => {} }; diff --git a/packages/coding-agent/test/utils/changelog-static-import.test.ts b/packages/coding-agent/test/utils/changelog-static-import.test.ts new file mode 100644 index 000000000..369dc991a --- /dev/null +++ b/packages/coding-agent/test/utils/changelog-static-import.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { VERSION } from "@oh-my-pi/pi-utils"; +import { resolveBundledChangelogPath } from "../../src/utils/changelog"; + +interface HeapProbeResult { + retainedChangelogStrings: number; +} + +interface BundleProbeResult { + version: string; + entries: number; +} + +const repoRoot = path.resolve(import.meta.dir, "..", "..", "..", ".."); +const heapProbePath = path.resolve(import.meta.dir, "..", "fixtures", "changelog-static-import-heap-probe.ts"); +const bundleProbePath = path.resolve(import.meta.dir, "..", "fixtures", "changelog-bundle-fallback-probe.ts"); +const utilsStubPath = path.resolve(import.meta.dir, "..", "fixtures", "changelog-utils-stub.ts"); + +async function runProbe(command: string[], cwd?: string): Promise { + const proc = Bun.spawn(command, { + cwd, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(exitCode, stderr).toBe(0); + return JSON.parse(stdout) as BundleProbeResult; +} + +describe("bundled changelog asset path resolution", () => { + const moduleUrl = new URL("file:///opt/omp/dist/cli.js"); + + test.each([ + ["Windows drive-letter", String.raw`C:\omp\dist\CHANGELOG.md`], + ["Windows UNC", String.raw`\\server\share\omp\CHANGELOG.md`], + ["POSIX", "/opt/omp/dist/CHANGELOG.md"], + ])("preserves an absolute %s path", (_kind, nativePath) => { + expect(resolveBundledChangelogPath(nativePath, moduleUrl)).toBe(nativePath); + }); + + test("resolves a relative emitted asset against the module", () => { + expect(resolveBundledChangelogPath("./CHANGELOG-hash.md", moduleUrl)).toEqual( + new URL("./CHANGELOG-hash.md", moduleUrl), + ); + }); +}); + +describe("changelog static import resources", () => { + test("does not retain the multi-megabyte changelog text before parsing", async () => { + const proc = Bun.spawn([process.execPath, heapProbePath], { + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + expect(exitCode, stderr).toBe(0); + expect(JSON.parse(stdout) as HeapProbeResult).toEqual({ retainedChangelogStrings: 0 }); + }, 30_000); + + test("reads the emitted changelog asset when run outside the bundle directory", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-changelog-bundle-")); + try { + const bundleDir = path.join(tempDir, "bundle"); + const unrelatedCwd = path.join(tempDir, "cwd"); + const missingPackageChangelogPath = path.join(tempDir, "missing-package", "CHANGELOG.md"); + await fs.mkdir(unrelatedCwd); + const sourceResult = await runProbe([process.execPath, bundleProbePath, missingPackageChangelogPath]); + + const buildProc = Bun.spawn( + [ + process.execPath, + "build", + bundleProbePath, + "--target=bun", + "--external=omp-legacy-pi-modules", + `--outdir=${bundleDir}`, + ], + { stderr: "pipe", stdout: "pipe" }, + ); + const [buildStdout, buildStderr, buildExitCode] = await Promise.all([ + new Response(buildProc.stdout).text(), + new Response(buildProc.stderr).text(), + buildProc.exited, + ]); + expect(buildExitCode, buildStdout + buildStderr).toBe(0); + + const outputs = await fs.readdir(bundleDir); + expect(outputs.some(output => output.endsWith(".md"))).toBe(true); + const bundleFilename = outputs.find(output => output.endsWith(".js")); + if (!bundleFilename) throw new Error("Changelog bundle build did not emit an entrypoint"); + const result = await runProbe( + [process.execPath, path.join(bundleDir, bundleFilename), missingPackageChangelogPath], + unrelatedCwd, + ); + + expect(result.version).toBe(VERSION); + expect(result.entries).toBe(sourceResult.entries); + } finally { + await fs.rm(tempDir, { force: true, recursive: true }); + } + }, 30_000); + + test("reads the emitted changelog asset from a compiled binary", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-changelog-compiled-")); + try { + const binaryPath = path.join(tempDir, "changelog-probe"); + const unrelatedCwd = path.join(tempDir, "cwd"); + const missingPackageChangelogPath = path.join(tempDir, "missing-package", "CHANGELOG.md"); + await fs.mkdir(unrelatedCwd); + const sourceResult = await runProbe([process.execPath, bundleProbePath, missingPackageChangelogPath]); + + const buildOutput = await Bun.build({ + entrypoints: [bundleProbePath], + root: repoRoot, + external: ["omp-legacy-pi-modules"], + plugins: [ + { + name: "changelog-utils-stub", + setup(build) { + build.onResolve({ filter: /^@oh-my-pi\/pi-utils$/ }, () => ({ path: utilsStubPath })); + build.onResolve({ filter: /^\.\.\/config$/ }, args => + args.importer.endsWith("/utils/changelog.ts") ? { path: utilsStubPath } : undefined, + ); + }, + }, + ], + compile: { + outfile: binaryPath, + autoloadBunfig: false, + autoloadDotenv: false, + autoloadTsconfig: false, + autoloadPackageJson: false, + }, + }); + expect(buildOutput.success, buildOutput.logs.map(log => log.message).join("\n")).toBe(true); + + const result = await runProbe([binaryPath, missingPackageChangelogPath], unrelatedCwd); + expect(result.version).toBe(VERSION); + expect(result.entries).toBe(sourceResult.entries); + } finally { + await fs.rm(tempDir, { force: true, recursive: true }); + } + }, 30_000); +}); diff --git a/packages/coding-agent/test/utils/changelog.test.ts b/packages/coding-agent/test/utils/changelog.test.ts index ad033cbbf..3e45195e8 100644 --- a/packages/coding-agent/test/utils/changelog.test.ts +++ b/packages/coding-agent/test/utils/changelog.test.ts @@ -17,6 +17,7 @@ import * as path from "node:path"; import { removeWithRetries, VERSION } from "@oh-my-pi/pi-utils"; import { type ChangelogEntry, + getNewEntries, parseChangelog, RECENT_CHANGELOG_ENTRY_LIMIT, readLastChangelogVersion, @@ -153,12 +154,17 @@ describe("selectStartupChangelog", () => { }); describe("parseChangelog", () => { - test("reads the embedded release history when no package path is available", async () => { + test("reads current source release data and filters versions newer than the previous release", async () => { const entries = await parseChangelog(undefined); const latest = entries[0]; + const previous = entries[1]; expect(`${latest?.major}.${latest?.minor}.${latest?.patch}`).toBe(VERSION); expect(latest?.content).toContain(`## [${VERSION}]`); + expect(previous).toBeDefined(); + + const previousVersion = `${previous?.major}.${previous?.minor}.${previous?.patch}`; + expect(getNewEntries(entries, previousVersion)).toEqual([latest]); }); });