feat(agent): pin managed runtimes and add update awareness (#2906)
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const DEFAULT_CATALOGUE_PATH = path.resolve(
|
||||
SCRIPT_DIR,
|
||||
"../apps/backend/internal/agent/agents/managed_npm_runtime_versions.json",
|
||||
);
|
||||
const STABLE_SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
export function parseStableVersion(value) {
|
||||
if (typeof value !== "string" || !STABLE_SEMVER.test(value)) {
|
||||
throw new Error(`value is not a stable SemVer: ${String(value)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertCatalogue(catalogue) {
|
||||
if (!catalogue || typeof catalogue !== "object" || Array.isArray(catalogue)) {
|
||||
throw new Error("runtime pin catalogue must be a JSON object");
|
||||
}
|
||||
const packages = Object.keys(catalogue);
|
||||
if (packages.length === 0) {
|
||||
throw new Error("runtime pin catalogue is empty");
|
||||
}
|
||||
for (const packageName of packages) {
|
||||
if (!packageName.trim()) {
|
||||
throw new Error("runtime pin catalogue contains an empty package name");
|
||||
}
|
||||
parseStableVersion(catalogue[packageName]);
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePins(catalogue, latestByPackage) {
|
||||
assertCatalogue(catalogue);
|
||||
if (!latestByPackage || typeof latestByPackage !== "object") {
|
||||
throw new Error("latest version lookup did not return an object");
|
||||
}
|
||||
|
||||
const next = {};
|
||||
let changed = false;
|
||||
for (const packageName of Object.keys(catalogue)) {
|
||||
const latest = latestByPackage[packageName];
|
||||
if (latest === undefined || latest === null || latest === "") {
|
||||
throw new Error(`latest version is missing for ${packageName}`);
|
||||
}
|
||||
parseStableVersion(latest);
|
||||
next[packageName] = latest;
|
||||
changed ||= latest !== catalogue[packageName];
|
||||
}
|
||||
return { catalogue: next, changed };
|
||||
}
|
||||
|
||||
export function formatCatalogue(catalogue) {
|
||||
assertCatalogue(catalogue);
|
||||
return `${JSON.stringify(catalogue, Object.keys(catalogue).sort(), 2)}\n`;
|
||||
}
|
||||
|
||||
async function fetchLatest(packageName, fetchImpl) {
|
||||
const response = await fetchImpl(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
|
||||
{ headers: { accept: "application/json" } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`npm lookup for ${packageName} returned HTTP ${response.status}`);
|
||||
}
|
||||
const metadata = await response.json();
|
||||
const latest = metadata?.["dist-tags"]?.latest;
|
||||
parseStableVersion(latest);
|
||||
return latest;
|
||||
}
|
||||
|
||||
export async function fetchLatestVersions(packages, fetchImpl = globalThis.fetch) {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("fetch is unavailable");
|
||||
}
|
||||
const pairs = await Promise.all(
|
||||
packages.map(async (packageName) => [packageName, await fetchLatest(packageName, fetchImpl)]),
|
||||
);
|
||||
return Object.fromEntries(pairs);
|
||||
}
|
||||
|
||||
export async function readCatalogue(cataloguePath = DEFAULT_CATALOGUE_PATH) {
|
||||
const raw = await fs.readFile(cataloguePath, "utf8");
|
||||
const catalogue = JSON.parse(raw);
|
||||
assertCatalogue(catalogue);
|
||||
return catalogue;
|
||||
}
|
||||
|
||||
export async function writeCatalogueAtomically(cataloguePath, catalogue) {
|
||||
const directory = path.dirname(cataloguePath);
|
||||
const temporaryPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(cataloguePath)}.${process.pid}.tmp`,
|
||||
);
|
||||
await fs.writeFile(temporaryPath, formatCatalogue(catalogue), "utf8");
|
||||
await fs.rename(temporaryPath, cataloguePath);
|
||||
}
|
||||
|
||||
export async function updateCatalogue({
|
||||
cataloguePath = DEFAULT_CATALOGUE_PATH,
|
||||
fetchImpl = globalThis.fetch,
|
||||
} = {}) {
|
||||
const catalogue = await readCatalogue(cataloguePath);
|
||||
const latestByPackage = await fetchLatestVersions(Object.keys(catalogue), fetchImpl);
|
||||
const result = updatePins(catalogue, latestByPackage);
|
||||
if (result.changed) {
|
||||
await writeCatalogueAtomically(cataloguePath, result.catalogue);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function outputChanged(outputPath, changed) {
|
||||
if (!outputPath) {
|
||||
return;
|
||||
}
|
||||
return fs.appendFile(outputPath, `changed=${changed ? "true" : "false"}\n`);
|
||||
}
|
||||
|
||||
export function parseArguments(argv) {
|
||||
let cataloguePath = DEFAULT_CATALOGUE_PATH;
|
||||
let outputPath = "";
|
||||
let positionalPathSeen = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--github-output") {
|
||||
outputPath = argv[index + 1] ?? "";
|
||||
if (!outputPath || outputPath.startsWith("--")) {
|
||||
throw new Error("--github-output requires a path");
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith("--")) {
|
||||
throw new Error(`unknown option: ${argument}`);
|
||||
}
|
||||
if (positionalPathSeen) {
|
||||
throw new Error("only one catalogue path is supported");
|
||||
}
|
||||
cataloguePath = argument;
|
||||
positionalPathSeen = true;
|
||||
}
|
||||
|
||||
return { cataloguePath, outputPath };
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const { cataloguePath, outputPath } = parseArguments(argv);
|
||||
const result = await updateCatalogue({ cataloguePath });
|
||||
await outputChanged(outputPath, result.changed);
|
||||
process.stdout.write(result.changed ? "Managed runtime pins changed.\n" : "Managed runtime pins are current.\n");
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(`Managed runtime pin update failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_CATALOGUE_PATH,
|
||||
parseStableVersion,
|
||||
parseArguments,
|
||||
updatePins,
|
||||
formatCatalogue,
|
||||
} from "./update-agent-runtime-pins.mjs";
|
||||
|
||||
const catalogue = {
|
||||
"@example/claude": "1.0.0",
|
||||
"opencode-ai": "2.0.0",
|
||||
};
|
||||
|
||||
test("updates only existing trusted catalogue entries", () => {
|
||||
const result = updatePins(catalogue, {
|
||||
"@example/claude": "1.1.0",
|
||||
"opencode-ai": "2.0.0",
|
||||
"@example/untrusted": "9.9.9",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.catalogue, {
|
||||
"@example/claude": "1.1.0",
|
||||
"opencode-ai": "2.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not report a change when every latest value matches", () => {
|
||||
const result = updatePins(catalogue, {
|
||||
"@example/claude": "1.0.0",
|
||||
"opencode-ai": "2.0.0",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, false);
|
||||
assert.deepEqual(result.catalogue, catalogue);
|
||||
});
|
||||
|
||||
test("rejects prerelease, malformed, and missing latest values before rewriting", () => {
|
||||
for (const latest of ["1.1.0-beta.1", "latest", "", undefined]) {
|
||||
assert.throws(
|
||||
() => updatePins(catalogue, { "@example/claude": latest, "opencode-ai": "2.0.0" }),
|
||||
/stable SemVer|latest version/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an invalid current catalogue pin", () => {
|
||||
assert.throws(
|
||||
() => updatePins({ "@example/claude": "1.0.0-beta.1" }, { "@example/claude": "1.1.0" }),
|
||||
/stable SemVer/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts build metadata but not prerelease syntax", () => {
|
||||
assert.equal(parseStableVersion("1.2.3+build.7"), "1.2.3+build.7");
|
||||
assert.throws(() => parseStableVersion("1.2.3-rc.1"), /stable SemVer/i);
|
||||
});
|
||||
|
||||
test("parses the workflow output flag before the default catalogue path", () => {
|
||||
assert.deepEqual(parseArguments(["--github-output", "/tmp/github-output"]), {
|
||||
cataloguePath: DEFAULT_CATALOGUE_PATH,
|
||||
outputPath: "/tmp/github-output",
|
||||
});
|
||||
});
|
||||
|
||||
test("formats the catalogue as one complete atomic-write payload", () => {
|
||||
assert.equal(
|
||||
formatCatalogue({ "@example/claude": "1.1.0", "opencode-ai": "2.0.0" }),
|
||||
'{\n "@example/claude": "1.1.0",\n "opencode-ai": "2.0.0"\n}\n',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user