// Collects plugin manifest and metric observations for gateway gauntlet reports. import fs from "node:fs"; import path from "node:path"; import JSON5 from "json5"; import { NON_PACKAGED_BUNDLED_PLUGIN_DIRS, collectBundledPluginBuildEntries, } from "./bundled-plugin-build-entries.mjs"; import { parsePositiveInt } from "./numeric-options.mjs"; const MANIFEST_NAMES = ["openclaw.plugin.json", "openclaw.plugin.json5"]; const ANSI_PATTERN = new RegExp(String.raw`\u001B\[[0-9;]*m`, "gu"); const QA_SUMMARY_MAX_BYTES_ENV = "OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_QA_SUMMARY_MAX_BYTES"; const DEFAULT_QA_SUMMARY_MAX_BYTES = 2 * 1024 * 1024; function readPositiveIntEnv(name, fallback) { const raw = process.env[name]; return raw === undefined || raw === "" ? fallback : parsePositiveInt(raw, name); } 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 pluginDir = path.dirname(manifestPath); const relativeManifestPath = path.relative(repoRoot, manifestPath); const commandAliases = collectCommandAliasRecords(manifest); return { id: manifest.id, buildId: path.basename(pluginDir), name: normalizeString(manifest.name) || manifest.id, dir: path.relative(repoRoot, pluginDir), 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), requiredPlugins: normalizeStringArray(manifest.requiresPlugins), 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 buildEntryDirs = new Set( collectBundledPluginBuildEntries({ cwd: repoRoot }).map((entry) => entry.id), ); const entries = fs .readdirSync(extensionsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .filter((entry) => buildEntryDirs.has(entry.name)) .flatMap((entry) => { const pluginDir = path.join(extensionsDir, entry.name); const manifestName = MANIFEST_NAMES.find((name) => fs.existsSync(path.join(pluginDir, name))); if (!manifestName) { return []; } if (NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(entry.name)) { return []; } const manifestPath = path.join(pluginDir, manifestName); const manifest = readPluginManifest(manifestPath); return [buildPluginMatrixEntry({ repoRoot, manifestPath, manifest })]; }); return entries.toSorted((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 collectRequiredPluginEntries(entries, plugins) { const byId = new Map(entries.map((entry) => [entry.id, entry])); const selectedIds = new Set(plugins.map((entry) => entry.id)); const required = new Map(); const visit = (requiredId, ownerId, trail) => { const entry = byId.get(requiredId); if (!entry) { throw new Error( `Bundled plugin "${ownerId}" requires unknown bundled plugin "${requiredId}"`, ); } const cycleIndex = trail.indexOf(requiredId); if (cycleIndex !== -1) { const cycle = [...trail.slice(cycleIndex), requiredId].join(" -> "); throw new Error(`Bundled plugin dependency cycle detected: ${cycle}`); } if (required.has(requiredId)) { return; } const nextTrail = [...trail, requiredId]; for (const transitiveRequiredId of entry.requiredPlugins ?? []) { visit(transitiveRequiredId, ownerId, nextTrail); } if (!selectedIds.has(requiredId)) { required.set(requiredId, entry); } }; for (const plugin of plugins) { for (const requiredId of plugin.requiredPlugins ?? []) { visit(requiredId, plugin.id, [plugin.id]); } } return [...required.values()]; } function collectPluginsWithRequiredEntries(entries, plugins) { const combined = new Map(); for (const plugin of collectRequiredPluginEntries(entries, plugins)) { combined.set(plugin.id, plugin); } for (const plugin of plugins) { combined.set(plugin.id, plugin); } return [...combined.values()]; } function median(values) { const sorted = values .filter((value) => typeof value === "number" && Number.isFinite(value)) .toSorted((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 firstWorkRow = rows.find((row) => row.phase !== "prebuild"); 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) { const coldStart = row === firstWorkRow; const cpuCoreRatio = phase === "qa:rpc" && typeof row.qaMetrics?.gatewayCpuCoreRatio === "number" ? row.qaMetrics.gatewayCpuCoreRatio : row.cpuCoreRatio; const wallMs = phase === "qa:rpc" && typeof row.qaMetrics?.wallMs === "number" ? row.qaMetrics.wallMs : row.wallMs; if ( typeof cpuCoreRatio === "number" && typeof wallMs === "number" && cpuCoreRatio >= cpuCoreWarn && wallMs >= hotWallWarnMs ) { observations.push({ kind: "phase-cpu-hot", pluginId: row.pluginId ?? null, phase, cpuCoreRatio, wallMs, ...(coldStart ? { coldStart } : {}), }); } 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, ...(coldStart ? { coldStart } : {}), }); } 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, ...(coldStart ? { coldStart } : {}), }); } 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, ...(coldStart ? { coldStart } : {}), }); } } } return observations; } function collectQaBaselineRegressionObservations(rows, thresholds = {}) { const baselinePluginId = thresholds.baselinePluginId ?? ""; const cpuRegressionMultiplier = thresholds.cpuRegressionMultiplier ?? 2; const wallRegressionMultiplier = thresholds.wallRegressionMultiplier ?? 2; const baseline = rows.find((row) => row.phase === "qa:rpc" && row.pluginId === baselinePluginId); const baselineMetrics = baseline?.qaMetrics; if (!baselineMetrics) { return []; } const observations = []; for (const row of rows) { if (row.phase !== "qa:rpc" || row.pluginId === baselinePluginId || !row.qaMetrics) { continue; } if ( typeof baselineMetrics.gatewayCpuCoreRatio === "number" && baselineMetrics.gatewayCpuCoreRatio > 0 && typeof row.qaMetrics.gatewayCpuCoreRatio === "number" && row.qaMetrics.gatewayCpuCoreRatio >= baselineMetrics.gatewayCpuCoreRatio * cpuRegressionMultiplier ) { observations.push({ kind: "qa-baseline-cpu-regression", pluginId: row.pluginId ?? null, cpuCoreRatio: row.qaMetrics.gatewayCpuCoreRatio, baselineCpuCoreRatio: baselineMetrics.gatewayCpuCoreRatio, multiplier: cpuRegressionMultiplier, }); } if ( typeof baselineMetrics.wallMs === "number" && baselineMetrics.wallMs > 0 && typeof row.qaMetrics.wallMs === "number" && row.qaMetrics.wallMs >= baselineMetrics.wallMs * wallRegressionMultiplier ) { observations.push({ kind: "qa-baseline-wall-regression", pluginId: row.pluginId ?? null, wallMs: row.qaMetrics.wallMs, baselineWallMs: baselineMetrics.wallMs, multiplier: wallRegressionMultiplier, }); } } return observations; } function buildGauntletPrebuildEnv(env, options = {}) { const buildIds = new Set(normalizeStringArray(options.buildIds)); const runtimeOnlyPrebuildEnv = options.skipDeclarationBuild ? { OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1" } : {}; const hasRuntimeOnlyPrebuildEnv = Object.keys(runtimeOnlyPrebuildEnv).length > 0; if (options.includePrivateQa) { for (const pluginId of NON_PACKAGED_BUNDLED_PLUGIN_DIRS) { buildIds.add(pluginId); } } if (!options.includePrivateQa) { return buildIds.size === 0 && !hasRuntimeOnlyPrebuildEnv ? env : { ...env, PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: env.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN ?? "false", ...runtimeOnlyPrebuildEnv, ...(buildIds.size > 0 ? { OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS: [...buildIds] .toSorted((left, right) => left.localeCompare(right)) .join(","), } : {}), }; } return { ...env, PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: env.PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN ?? "false", ...runtimeOnlyPrebuildEnv, OPENCLAW_BUILD_PRIVATE_QA: "1", OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", ...(buildIds.size > 0 ? { OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS: [...buildIds] .toSorted((left, right) => left.localeCompare(right)) .join(","), } : {}), }; } function detectCommandDiagnosticFailure(stdout, stderr) { const output = `${stdout}\n${stderr}`.replace(ANSI_PATTERN, ""); if (/^\[plugins\]\s+\S+\s+failed to load from\s+/mu.test(output)) { return "plugin-load-failure"; } return null; } 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; } function readQaSuiteSummary(summaryPath) { if (!fs.existsSync(summaryPath)) { return { diagnosticFailure: "qa-summary-missing", diagnosticDetail: `expected QA suite summary at ${summaryPath}`, summary: null, }; } try { const summary = JSON.parse(readQaSuiteSummaryText(summaryPath)); const invalidReason = validateQaSuiteSummary(summary); if (invalidReason) { return { diagnosticFailure: "qa-summary-invalid", diagnosticDetail: invalidReason, summary: null, }; } if (summary.counts.failed > 0) { return { diagnosticFailure: "qa-summary-failed-scenarios", diagnosticDetail: `QA suite reported ${summary.counts.failed} failed scenario(s)`, summary, }; } return { diagnosticFailure: null, diagnosticDetail: null, summary, }; } catch (error) { return { diagnosticFailure: "qa-summary-invalid", diagnosticDetail: error instanceof Error ? error.message : String(error), summary: null, }; } } function readQaSuiteSummaryText(summaryPath) { const maxBytes = readPositiveIntEnv(QA_SUMMARY_MAX_BYTES_ENV, DEFAULT_QA_SUMMARY_MAX_BYTES); const stat = fs.statSync(summaryPath); if (!stat.isFile()) { throw new Error(`QA suite summary is not a file: ${summaryPath}`); } if (stat.size > maxBytes) { throw new Error( `QA suite summary exceeded ${maxBytes} bytes: ${summaryPath} (${stat.size} bytes)`, ); } const text = fs.readFileSync(summaryPath, "utf8"); const bytes = Buffer.byteLength(text, "utf8"); if (bytes > maxBytes) { throw new Error(`QA suite summary exceeded ${maxBytes} bytes: ${summaryPath} (${bytes} bytes)`); } return text; } function validateQaSuiteSummary(summary) { if (!summary || typeof summary !== "object" || Array.isArray(summary)) { return "QA suite summary must be a JSON object"; } if (!Array.isArray(summary.scenarios)) { return "QA suite summary missing scenarios array"; } if ( !summary.counts || typeof summary.counts !== "object" || !isNonNegativeInteger(summary.counts.total) || !isNonNegativeInteger(summary.counts.passed) || !isNonNegativeInteger(summary.counts.failed) ) { return "QA suite summary missing numeric counts"; } if (!summary.run || typeof summary.run !== "object" || Array.isArray(summary.run)) { return "QA suite summary missing run metadata"; } const statusCounts = { failed: 0, passed: 0, skipped: 0 }; for (const scenario of summary.scenarios) { if (!scenario || typeof scenario !== "object" || Array.isArray(scenario)) { return "QA suite summary scenario entries must be objects"; } if (scenario.status === "pass") { statusCounts.passed += 1; } else if (scenario.status === "fail") { statusCounts.failed += 1; } else if (scenario.status === "skip") { statusCounts.skipped += 1; } else { return `QA suite summary scenario has invalid status: ${String(scenario.status)}`; } } if (summary.counts.total !== summary.scenarios.length) { return `QA suite summary total count mismatch: counts.total=${summary.counts.total}, scenarios=${summary.scenarios.length}`; } if (summary.counts.passed !== statusCounts.passed) { return `QA suite summary passed count mismatch: counts.passed=${summary.counts.passed}, passed scenarios=${statusCounts.passed}`; } if (summary.counts.failed !== statusCounts.failed) { return `QA suite summary failed count mismatch: counts.failed=${summary.counts.failed}, failed scenarios=${statusCounts.failed}`; } if ( summary.counts.skipped !== undefined && (!isNonNegativeInteger(summary.counts.skipped) || summary.counts.skipped !== statusCounts.skipped) ) { return `QA suite summary skipped count mismatch: counts.skipped=${String(summary.counts.skipped)}, skipped scenarios=${statusCounts.skipped}`; } if (summary.counts.failed === 0) { const emptyPassedScenario = summary.scenarios.find( (scenario) => scenario.status === "pass" && (!Array.isArray(scenario.steps) || scenario.steps.length === 0), ); if (emptyPassedScenario) { return `QA suite summary passed scenario has no step evidence: ${String(emptyPassedScenario.name ?? "")}`; } } return null; } function isNonNegativeInteger(value) { return Number.isInteger(value) && value >= 0; } export { collectQaBaselineRegressionObservations, collectPluginsWithRequiredEntries, collectRequiredPluginEntries, collectGatewayCpuObservations, collectMetricObservations, buildGauntletPrebuildEnv, detectCommandDiagnosticFailure, discoverBundledPluginManifests, readQaSuiteSummary, schemaHasRequiredFields, selectPluginEntries, };