test(plugins): add gateway gauntlet

This commit is contained in:
Vincent Koc
2026-04-28 16:17:13 -07:00
parent ef58307f84
commit a6dfaaeb4e
9 changed files with 1119 additions and 2 deletions

View File

@@ -163,7 +163,7 @@ function collectObservations(params) {
const observations = [];
for (const result of params.startup?.results ?? []) {
const cpuCoreMax = result.summary?.cpuCoreRatio?.max;
const wallMax = result.summary?.readyz?.max ?? result.summary?.healthz?.max;
const wallMax = result.summary?.readyzMs?.max ?? result.summary?.healthzMs?.max;
if (
typeof cpuCoreMax === "number" &&
typeof wallMax === "number" &&

View File

@@ -0,0 +1,578 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import {
collectGatewayCpuObservations,
collectMetricObservations,
discoverBundledPluginManifests,
selectPluginEntries,
} from "./lib/plugin-gateway-gauntlet.mjs";
const DEFAULT_QA_SCENARIOS = [
"channel-chat-baseline",
"memory-failure-fallback",
"gateway-restart-inflight-run",
];
const DEFAULT_CPU_CORE_WARN = 0.9;
const DEFAULT_HOT_WALL_WARN_MS = 30_000;
const DEFAULT_MAX_RSS_WARN_MB = 1536;
const DEFAULT_QA_PLUGIN_CHUNK_SIZE = 12;
function parseArgs(argv) {
const options = {
repoRoot: process.cwd(),
outputDir: path.join(
process.cwd(),
".artifacts",
"plugin-gateway-gauntlet",
new Date().toISOString().replace(/[:.]/g, "-"),
),
pluginIds: [],
shardTotal: readOptionalPositiveIntEnv("OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_TOTAL") ?? 1,
shardIndex: readOptionalNonNegativeIntEnv("OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_INDEX") ?? 0,
limit: undefined,
skipPrebuild: false,
skipLifecycle: false,
skipQa: false,
skipSlashHelp: false,
qaScenarios: [],
qaPluginChunkSize: DEFAULT_QA_PLUGIN_CHUNK_SIZE,
cpuCoreWarn: DEFAULT_CPU_CORE_WARN,
hotWallWarnMs: DEFAULT_HOT_WALL_WARN_MS,
maxRssWarnMb: DEFAULT_MAX_RSS_WARN_MB,
wallAnomalyMultiplier: 3,
rssAnomalyMultiplier: 2.5,
commandTimeoutMs: 120_000,
buildTimeoutMs: 600_000,
qaTimeoutMs: 900_000,
};
const envIds = normalizeCsv(process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS);
options.pluginIds.push(...envIds);
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = () => {
const value = argv[index + 1];
if (!value) {
throw new Error(`Missing value for ${arg}`);
}
index += 1;
return value;
};
switch (arg) {
case "--":
break;
case "--repo-root":
options.repoRoot = path.resolve(readValue());
break;
case "--output-dir":
options.outputDir = path.resolve(readValue());
break;
case "--plugin":
options.pluginIds.push(readValue());
break;
case "--shard-total":
options.shardTotal = parsePositiveInt(readValue(), "--shard-total");
break;
case "--shard-index":
options.shardIndex = parseNonNegativeInt(readValue(), "--shard-index");
break;
case "--limit":
options.limit = parsePositiveInt(readValue(), "--limit");
break;
case "--qa-scenario":
options.qaScenarios.push(readValue());
break;
case "--qa-plugin-chunk-size":
options.qaPluginChunkSize = parsePositiveInt(readValue(), "--qa-plugin-chunk-size");
break;
case "--cpu-core-warn":
options.cpuCoreWarn = parsePositiveNumber(readValue(), "--cpu-core-warn");
break;
case "--hot-wall-warn-ms":
options.hotWallWarnMs = parsePositiveInt(readValue(), "--hot-wall-warn-ms");
break;
case "--max-rss-warn-mb":
options.maxRssWarnMb = parsePositiveNumber(readValue(), "--max-rss-warn-mb");
break;
case "--wall-anomaly-multiplier":
options.wallAnomalyMultiplier = parsePositiveNumber(
readValue(),
"--wall-anomaly-multiplier",
);
break;
case "--rss-anomaly-multiplier":
options.rssAnomalyMultiplier = parsePositiveNumber(readValue(), "--rss-anomaly-multiplier");
break;
case "--command-timeout-ms":
options.commandTimeoutMs = parsePositiveInt(readValue(), "--command-timeout-ms");
break;
case "--build-timeout-ms":
options.buildTimeoutMs = parsePositiveInt(readValue(), "--build-timeout-ms");
break;
case "--qa-timeout-ms":
options.qaTimeoutMs = parsePositiveInt(readValue(), "--qa-timeout-ms");
break;
case "--skip-prebuild":
options.skipPrebuild = true;
break;
case "--skip-lifecycle":
options.skipLifecycle = true;
break;
case "--skip-qa":
options.skipQa = true;
break;
case "--skip-slash-help":
options.skipSlashHelp = true;
break;
case "--help":
printHelp();
process.exit(0);
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (options.qaScenarios.length === 0) {
options.qaScenarios = [...DEFAULT_QA_SCENARIOS];
}
return options;
}
function printHelp() {
console.log(`Usage: pnpm test:plugins:gateway-gauntlet [options]
Runs a shardable bundled-plugin lifecycle, slash inventory, and QA gateway perf gauntlet.
Options:
--plugin <id> Plugin id to include, repeatable
--shard-total <count> Total plugin shards (default: env or 1)
--shard-index <index> Zero-based shard index (default: env or 0)
--limit <count> Limit selected plugins after sharding
--output-dir <path> Artifact directory
--qa-scenario <id> QA Lab scenario id, repeatable
--qa-plugin-chunk-size <count> Plugins enabled per QA run (default: 12)
--cpu-core-warn <ratio> Hot CPU threshold (default: 0.9)
--hot-wall-warn-ms <ms> Minimum wall time for hot CPU observations (default: 30000)
--max-rss-warn-mb <mb> Maximum RSS warning threshold (default: 1536)
--skip-prebuild Skip the upfront build used to avoid per-command rebuild noise
--skip-lifecycle Skip plugin install/inspect/disable/enable/doctor/uninstall
--skip-qa Skip QA Lab RPC conversation runs
--skip-slash-help Skip CLI help probes for plugin-declared command aliases
`);
}
function normalizeCsv(raw) {
return raw
? raw
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [];
}
function readOptionalPositiveIntEnv(name) {
const raw = process.env[name];
return raw ? parsePositiveInt(raw, name) : undefined;
}
function readOptionalNonNegativeIntEnv(name) {
const raw = process.env[name];
return raw ? parseNonNegativeInt(raw, name) : undefined;
}
function parsePositiveInt(raw, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value < 1) {
throw new Error(`${label} must be a positive integer`);
}
return value;
}
function parseNonNegativeInt(raw, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${label} must be a non-negative integer`);
}
return value;
}
function parsePositiveNumber(raw, label) {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${label} must be a positive number`);
}
return value;
}
function pnpmCommand() {
return process.platform === "win32" ? "pnpm.cmd" : "pnpm";
}
function openclawCommand(repoRoot, args) {
return {
command: process.execPath,
args: [path.join(repoRoot, "scripts", "run-node.mjs"), ...args],
};
}
function chunkArray(values, chunkSize) {
const chunks = [];
for (let index = 0; index < values.length; index += chunkSize) {
chunks.push(values.slice(index, index + chunkSize));
}
return chunks;
}
function toRepoRelativePath(repoRoot, absolutePath) {
const relativePath = path.relative(repoRoot, absolutePath);
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error(`Output path must stay inside repo root: ${absolutePath}`);
}
return relativePath;
}
function createIsolatedEnv(repoRoot, runRoot) {
const home = path.join(runRoot, "home");
const stateDir = path.join(runRoot, "state");
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true });
return {
...process.env,
HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_CACHE_HOME: path.join(home, ".cache"),
XDG_DATA_HOME: path.join(home, ".local", "share"),
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
OPENCLAW_LOG_DIR: path.join(runRoot, "logs"),
OPENCLAW_QA_SUITE_PROGRESS: process.env.OPENCLAW_QA_SUITE_PROGRESS ?? "1",
PATH: process.env.PATH,
PWD: repoRoot,
};
}
function hasUsrBinTime() {
return fs.existsSync("/usr/bin/time");
}
function timeWrapperArgs(command, args) {
if (!hasUsrBinTime()) {
return { command, args, mode: "none" };
}
if (process.platform === "darwin") {
return { command: "/usr/bin/time", args: ["-l", command, ...args], mode: "bsd" };
}
return { command: "/usr/bin/time", args: ["-v", command, ...args], mode: "gnu" };
}
function parseTimedMetrics(stderr, wallMs, mode) {
let userSeconds = null;
let systemSeconds = null;
let maxRssMb = null;
if (mode === "gnu") {
userSeconds = parseFirstFloat(stderr, /User time \(seconds\):\s*([0-9.]+)/u);
systemSeconds = parseFirstFloat(stderr, /System time \(seconds\):\s*([0-9.]+)/u);
const maxRssKb = parseFirstFloat(stderr, /Maximum resident set size \(kbytes\):\s*([0-9.]+)/u);
maxRssMb = maxRssKb == null ? null : maxRssKb / 1024;
} else if (mode === "bsd") {
userSeconds = parseFirstFloat(stderr, /[0-9.]+\s+real\s+([0-9.]+)\s+user/u);
systemSeconds = parseFirstFloat(stderr, /([0-9.]+)\s+sys/u);
const maxRssBytes = parseFirstFloat(stderr, /([0-9]+)\s+maximum resident set size/u);
maxRssMb = maxRssBytes == null ? null : maxRssBytes / 1024 / 1024;
}
const cpuMs =
userSeconds == null && systemSeconds == null
? null
: ((userSeconds ?? 0) + (systemSeconds ?? 0)) * 1000;
return {
wallMs,
cpuMs,
cpuCoreRatio: cpuMs == null || wallMs <= 0 ? null : cpuMs / wallMs,
maxRssMb,
};
}
function parseFirstFloat(value, pattern) {
const match = value.match(pattern);
if (!match) {
return null;
}
const parsed = Number(match[1]);
return Number.isFinite(parsed) ? parsed : null;
}
function stripAnsi(value) {
return value.replace(/\u001B\[[0-9;]*m/gu, "");
}
function writeCommandLog(params) {
const { logDir, label, stdout, stderr } = params;
fs.mkdirSync(logDir, { recursive: true });
const safeLabel = label.replace(/[^a-zA-Z0-9_.-]+/gu, "_");
const logPath = path.join(logDir, `${safeLabel}.log`);
fs.writeFileSync(
logPath,
[`$ ${params.command.join(" ")}`, "", stripAnsi(stdout), stripAnsi(stderr)].join("\n"),
"utf8",
);
return logPath;
}
function runMeasuredCommand(params) {
const { command, args, mode } = timeWrapperArgs(params.command, params.args);
const started = process.hrtime.bigint();
const result = spawnSync(command, args, {
cwd: params.cwd,
env: params.env,
encoding: "utf8",
timeout: params.timeoutMs,
maxBuffer: 16 * 1024 * 1024,
});
const wallMs = Number(process.hrtime.bigint() - started) / 1_000_000;
const status = result.status ?? (result.signal ? 1 : 0);
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
const logPath = writeCommandLog({
logDir: params.logDir,
label: params.label,
command: [params.command, ...params.args],
stdout,
stderr,
});
return {
label: params.label,
phase: params.phase,
pluginId: params.pluginId ?? null,
status,
signal: result.signal ?? null,
timedOut: result.error?.code === "ETIMEDOUT",
logPath,
...parseTimedMetrics(stderr, wallMs, mode),
};
}
function runPluginLifecycle(params) {
for (const plugin of params.plugins) {
const commands = [
["install", ["install", plugin.id]],
["inspect", ["inspect", plugin.id, "--json"]],
["disable", ["disable", plugin.id]],
["enable", ["enable", plugin.id]],
["doctor", ["doctor"]],
["uninstall", ["uninstall", plugin.id, "--force"]],
];
for (const [phase, args] of commands) {
process.stderr.write(`[plugin-gauntlet] ${plugin.id} ${phase}\n`);
params.rows.push(
runMeasuredCommand({
cwd: params.repoRoot,
env: params.env,
logDir: path.join(params.outputDir, "logs", "lifecycle"),
...openclawCommand(params.repoRoot, ["plugins", ...args]),
label: `${plugin.id}-${phase}`,
phase: `lifecycle:${phase}`,
pluginId: plugin.id,
timeoutMs: params.commandTimeoutMs,
}),
);
}
}
}
function runSlashHelpProbes(params) {
for (const plugin of params.plugins) {
for (const alias of plugin.cliCommandAliases) {
const command = alias.cliCommand ?? alias.name;
process.stderr.write(`[plugin-gauntlet] ${plugin.id} slash-help /${alias.name}\n`);
params.rows.push(
runMeasuredCommand({
cwd: params.repoRoot,
env: params.env,
logDir: path.join(params.outputDir, "logs", "slash-help"),
...openclawCommand(params.repoRoot, [command, "--help"]),
label: `${plugin.id}-slash-${alias.name}`,
phase: "slash:help",
pluginId: plugin.id,
timeoutMs: params.commandTimeoutMs,
}),
);
}
}
}
function runQaChunks(params) {
const chunks = chunkArray(params.plugins, params.qaPluginChunkSize);
const summaries = [];
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
const outputDir = path.join(
params.outputDir,
"qa-suite",
`chunk-${String(index).padStart(2, "0")}`,
);
const outputArg = toRepoRelativePath(params.repoRoot, outputDir);
const pluginIds = chunk.map((plugin) => plugin.id);
process.stderr.write(
`[plugin-gauntlet] qa chunk ${index + 1}/${chunks.length}: ${pluginIds.join(",")}\n`,
);
const row = runMeasuredCommand({
cwd: params.repoRoot,
env: params.env,
logDir: path.join(params.outputDir, "logs", "qa-suite"),
...openclawCommand(params.repoRoot, [
"qa",
"suite",
"--provider-mode",
"mock-openai",
"--concurrency",
"1",
"--output-dir",
outputArg,
...params.qaScenarios.flatMap((scenario) => ["--scenario", scenario]),
...pluginIds.flatMap((pluginId) => ["--enable-plugin", pluginId]),
]),
label: `qa-chunk-${String(index).padStart(2, "0")}`,
phase: "qa:rpc",
timeoutMs: params.qaTimeoutMs,
});
params.rows.push({ ...row, pluginId: pluginIds.join(",") });
const summaryPath = path.join(outputDir, "qa-suite-summary.json");
if (fs.existsSync(summaryPath)) {
summaries.push(JSON.parse(fs.readFileSync(summaryPath, "utf8")));
}
}
return summaries;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const repoRoot = path.resolve(options.repoRoot);
fs.mkdirSync(options.outputDir, { recursive: true });
const runRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-gauntlet-"));
const env = createIsolatedEnv(repoRoot, runRoot);
const matrix = discoverBundledPluginManifests(repoRoot);
const selectedPlugins = selectPluginEntries(matrix, {
ids: options.pluginIds,
shardTotal: options.shardTotal,
shardIndex: options.shardIndex,
limit: options.limit,
});
const rows = [];
if (!options.skipPrebuild && (selectedPlugins.length > 0 || !options.skipQa)) {
process.stderr.write("[plugin-gauntlet] prebuild\n");
rows.push(
runMeasuredCommand({
cwd: repoRoot,
env,
logDir: path.join(options.outputDir, "logs", "prebuild"),
command: pnpmCommand(),
args: ["build"],
label: "prebuild",
phase: "prebuild",
timeoutMs: options.buildTimeoutMs,
}),
);
}
const prebuildFailed = rows.some(
(row) => row.phase === "prebuild" && (row.status !== 0 || row.timedOut),
);
if (!prebuildFailed && !options.skipLifecycle) {
runPluginLifecycle({
repoRoot,
outputDir: options.outputDir,
env,
plugins: selectedPlugins,
rows,
commandTimeoutMs: options.commandTimeoutMs,
});
}
if (!prebuildFailed && !options.skipSlashHelp) {
runSlashHelpProbes({
repoRoot,
outputDir: options.outputDir,
env,
plugins: selectedPlugins,
rows,
commandTimeoutMs: options.commandTimeoutMs,
});
}
const qaSummaries =
options.skipQa || prebuildFailed
? []
: runQaChunks({
repoRoot,
outputDir: options.outputDir,
env,
plugins: selectedPlugins,
rows,
qaScenarios: options.qaScenarios,
qaPluginChunkSize: options.qaPluginChunkSize,
qaTimeoutMs: options.qaTimeoutMs,
});
const metricObservations = collectMetricObservations(rows, {
cpuCoreWarn: options.cpuCoreWarn,
hotWallWarnMs: options.hotWallWarnMs,
maxRssWarnMb: options.maxRssWarnMb,
wallAnomalyMultiplier: options.wallAnomalyMultiplier,
rssAnomalyMultiplier: options.rssAnomalyMultiplier,
});
const gatewayObservations = qaSummaries.flatMap((qa) =>
collectGatewayCpuObservations({
startup: null,
qa,
cpuCoreWarn: options.cpuCoreWarn,
hotWallWarnMs: options.hotWallWarnMs,
}),
);
const failures = rows.filter((row) => row.status !== 0 || row.timedOut);
const summary = {
generatedAt: new Date().toISOString(),
repoRoot,
outputDir: options.outputDir,
isolatedRunRoot: runRoot,
selectedPluginCount: selectedPlugins.length,
totalPluginCount: matrix.length,
options: {
pluginIds: options.pluginIds,
shardTotal: options.shardTotal,
shardIndex: options.shardIndex,
limit: options.limit ?? null,
qaScenarios: options.qaScenarios,
qaPluginChunkSize: options.qaPluginChunkSize,
skipLifecycle: options.skipLifecycle,
skipQa: options.skipQa,
skipSlashHelp: options.skipSlashHelp,
skipPrebuild: options.skipPrebuild,
thresholds: {
cpuCoreWarn: options.cpuCoreWarn,
hotWallWarnMs: options.hotWallWarnMs,
maxRssWarnMb: options.maxRssWarnMb,
wallAnomalyMultiplier: options.wallAnomalyMultiplier,
rssAnomalyMultiplier: options.rssAnomalyMultiplier,
},
},
matrix,
selectedPlugins,
rows,
observations: [...metricObservations, ...gatewayObservations],
failures,
};
const summaryPath = path.join(options.outputDir, "plugin-gateway-gauntlet-summary.json");
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
process.stdout.write(`[plugin-gauntlet] summary: ${summaryPath}\n`);
process.stdout.write(
`[plugin-gauntlet] plugins=${selectedPlugins.length}/${matrix.length} rows=${rows.length} failures=${failures.length} observations=${summary.observations.length}\n`,
);
if (failures.length > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View File

@@ -0,0 +1,326 @@
import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
const MANIFEST_NAMES = ["openclaw.plugin.json", "openclaw.plugin.json5"];
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
function normalizeStringArray(value) {
return Array.isArray(value)
? value.map((entry) => normalizeString(entry)).filter((entry) => entry.length > 0)
: [];
}
function readPluginManifest(manifestPath) {
const raw = fs.readFileSync(manifestPath, "utf8");
const parsed = manifestPath.endsWith(".json5") ? JSON5.parse(raw) : JSON.parse(raw);
if (!isPlainObject(parsed)) {
throw new Error(`Plugin manifest must be an object: ${manifestPath}`);
}
const id = normalizeString(parsed.id);
if (!id) {
throw new Error(`Plugin manifest is missing id: ${manifestPath}`);
}
return parsed;
}
function schemaHasRequiredFields(schema, seen = new Set()) {
if (!isPlainObject(schema) || seen.has(schema)) {
return false;
}
seen.add(schema);
if (Array.isArray(schema.required) && schema.required.length > 0) {
return true;
}
for (const key of ["properties", "patternProperties", "$defs", "definitions"]) {
const children = schema[key];
if (!isPlainObject(children)) {
continue;
}
for (const child of Object.values(children)) {
if (schemaHasRequiredFields(child, seen)) {
return true;
}
}
}
for (const key of ["items", "additionalProperties", "contains", "not", "if", "then", "else"]) {
if (schemaHasRequiredFields(schema[key], seen)) {
return true;
}
}
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
const children = schema[key];
if (!Array.isArray(children)) {
continue;
}
if (children.some((child) => schemaHasRequiredFields(child, seen))) {
return true;
}
}
return false;
}
function collectCommandAliasRecords(manifest) {
const aliases = Array.isArray(manifest.commandAliases) ? manifest.commandAliases : [];
return aliases
.map((alias) => {
if (typeof alias === "string") {
const name = normalizeString(alias);
return name ? { name, kind: "runtime-slash", cliCommand: null } : null;
}
if (!isPlainObject(alias)) {
return null;
}
const name = normalizeString(alias.name);
if (!name) {
return null;
}
return {
name,
kind: normalizeString(alias.kind) || "runtime-slash",
cliCommand: normalizeString(alias.cliCommand) || null,
};
})
.filter(Boolean);
}
function collectAuthMethods(manifest) {
const auth = Array.isArray(manifest.auth) ? manifest.auth : [];
return auth
.map((entry) => (isPlainObject(entry) ? normalizeString(entry.method) : ""))
.filter((method) => method.length > 0);
}
function collectOnboardingScopes(manifest) {
const scopes = new Set();
const addScopes = (value) => {
for (const scope of normalizeStringArray(value)) {
scopes.add(scope);
}
};
addScopes(manifest.onboardingScopes);
if (Array.isArray(manifest.auth)) {
for (const entry of manifest.auth) {
if (isPlainObject(entry)) {
addScopes(entry.onboardingScopes);
}
}
}
return [...scopes];
}
function buildPluginMatrixEntry(params) {
const { repoRoot, manifestPath, manifest } = params;
const relativeManifestPath = path.relative(repoRoot, manifestPath);
const commandAliases = collectCommandAliasRecords(manifest);
return {
id: manifest.id,
name: normalizeString(manifest.name) || manifest.id,
dir: path.relative(repoRoot, path.dirname(manifestPath)),
manifestPath: relativeManifestPath,
enabledByDefault: manifest.enabledByDefault === true,
activation: isPlainObject(manifest.activation) ? manifest.activation : {},
providers: normalizeStringArray(manifest.providers),
channels: normalizeStringArray(manifest.channels),
skills: normalizeStringArray(manifest.skills),
authMethods: collectAuthMethods(manifest),
onboardingScopes: collectOnboardingScopes(manifest),
hasConfigSchema: isPlainObject(manifest.configSchema),
hasRequiredConfigFields: schemaHasRequiredFields(manifest.configSchema),
commandAliases,
cliCommandAliases: commandAliases.filter((alias) => alias.cliCommand),
runtimeSlashAliases: commandAliases.filter((alias) => alias.kind === "runtime-slash"),
};
}
function discoverBundledPluginManifests(repoRoot) {
const extensionsDir = path.join(repoRoot, "extensions");
const entries = fs
.readdirSync(extensionsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.flatMap((entry) => {
const pluginDir = path.join(extensionsDir, entry.name);
const manifestName = MANIFEST_NAMES.find((name) => fs.existsSync(path.join(pluginDir, name)));
if (!manifestName) {
return [];
}
const manifestPath = path.join(pluginDir, manifestName);
const manifest = readPluginManifest(manifestPath);
return [buildPluginMatrixEntry({ repoRoot, manifestPath, manifest })];
});
return entries.sort((left, right) => left.id.localeCompare(right.id));
}
function selectPluginEntries(entries, options = {}) {
const ids = new Set(normalizeStringArray(options.ids));
let selected = ids.size > 0 ? entries.filter((entry) => ids.has(entry.id)) : [...entries];
const missingIds = [...ids].filter((id) => !entries.some((entry) => entry.id === id));
if (missingIds.length > 0) {
throw new Error(`Unknown bundled plugin id(s): ${missingIds.join(", ")}`);
}
const shardTotal = options.shardTotal ?? 1;
const shardIndex = options.shardIndex ?? 0;
if (!Number.isInteger(shardTotal) || shardTotal < 1) {
throw new Error("--shard-total must be a positive integer");
}
if (!Number.isInteger(shardIndex) || shardIndex < 0 || shardIndex >= shardTotal) {
throw new Error("--shard-index must be in range [0, shard-total)");
}
selected = selected.filter((_, index) => index % shardTotal === shardIndex);
if (options.limit !== undefined) {
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new Error("--limit must be a positive integer");
}
selected = selected.slice(0, options.limit);
}
return selected;
}
function median(values) {
const sorted = values
.filter((value) => typeof value === "number" && Number.isFinite(value))
.sort((left, right) => left - right);
if (sorted.length === 0) {
return null;
}
const midpoint = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1 ? sorted[midpoint] : (sorted[midpoint - 1] + sorted[midpoint]) / 2;
}
function groupByPhase(rows) {
const phases = new Map();
for (const row of rows) {
const phase = normalizeString(row.phase) || "unknown";
const current = phases.get(phase) ?? [];
current.push(row);
phases.set(phase, current);
}
return phases;
}
function collectMetricObservations(rows, thresholds = {}) {
const cpuCoreWarn = thresholds.cpuCoreWarn ?? 0.9;
const hotWallWarnMs = thresholds.hotWallWarnMs ?? 30_000;
const wallAnomalyMultiplier = thresholds.wallAnomalyMultiplier ?? 3;
const maxRssWarnMb = thresholds.maxRssWarnMb ?? null;
const rssAnomalyMultiplier = thresholds.rssAnomalyMultiplier ?? 2.5;
const observations = [];
for (const [phase, phaseRows] of groupByPhase(rows)) {
const wallMedianMs = median(phaseRows.map((row) => row.wallMs));
const rssMedianMb = median(phaseRows.map((row) => row.maxRssMb));
for (const row of phaseRows) {
if (
typeof row.cpuCoreRatio === "number" &&
typeof row.wallMs === "number" &&
row.cpuCoreRatio >= cpuCoreWarn &&
row.wallMs >= hotWallWarnMs
) {
observations.push({
kind: "phase-cpu-hot",
pluginId: row.pluginId ?? null,
phase,
cpuCoreRatio: row.cpuCoreRatio,
wallMs: row.wallMs,
});
}
if (
wallMedianMs !== null &&
phaseRows.length >= 3 &&
typeof row.wallMs === "number" &&
row.wallMs >= wallMedianMs * wallAnomalyMultiplier
) {
observations.push({
kind: "phase-wall-anomaly",
pluginId: row.pluginId ?? null,
phase,
wallMs: row.wallMs,
medianWallMs: wallMedianMs,
multiplier: wallAnomalyMultiplier,
});
}
if (
typeof maxRssWarnMb === "number" &&
typeof row.maxRssMb === "number" &&
row.maxRssMb >= maxRssWarnMb
) {
observations.push({
kind: "phase-rss-high",
pluginId: row.pluginId ?? null,
phase,
maxRssMb: row.maxRssMb,
thresholdMb: maxRssWarnMb,
});
}
if (
rssMedianMb !== null &&
rssMedianMb > 0 &&
phaseRows.length >= 3 &&
typeof row.maxRssMb === "number" &&
row.maxRssMb >= rssMedianMb * rssAnomalyMultiplier
) {
observations.push({
kind: "phase-rss-anomaly",
pluginId: row.pluginId ?? null,
phase,
maxRssMb: row.maxRssMb,
medianRssMb: rssMedianMb,
multiplier: rssAnomalyMultiplier,
});
}
}
}
return observations;
}
function collectGatewayCpuObservations(params) {
const observations = [];
for (const result of params.startup?.results ?? []) {
const cpuCoreMax = result.summary?.cpuCoreRatio?.max;
const wallMax = result.summary?.readyzMs?.max ?? result.summary?.healthzMs?.max;
if (
typeof cpuCoreMax === "number" &&
typeof wallMax === "number" &&
cpuCoreMax >= params.cpuCoreWarn &&
wallMax >= params.hotWallWarnMs
) {
observations.push({
kind: "startup-cpu-hot",
id: result.id,
cpuCoreRatioMax: cpuCoreMax,
wallMsMax: wallMax,
});
}
}
const qaCpuCoreRatio = params.qa?.metrics?.gatewayCpuCoreRatio;
const qaWallMs = params.qa?.metrics?.wallMs;
if (
typeof qaCpuCoreRatio === "number" &&
typeof qaWallMs === "number" &&
qaCpuCoreRatio >= params.cpuCoreWarn &&
qaWallMs >= params.hotWallWarnMs
) {
observations.push({
kind: "qa-cpu-hot",
id: "qa-suite",
cpuCoreRatio: qaCpuCoreRatio,
wallMs: qaWallMs,
});
}
return observations;
}
export {
collectCommandAliasRecords,
collectGatewayCpuObservations,
collectMetricObservations,
discoverBundledPluginManifests,
schemaHasRequiredFields,
selectPluginEntries,
};