mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 06:03:39 -06:00
c70aee247e
* refactor(scripts): migrate JavaScript tools to TypeScript * fix(ci): keep changed-scope preflight zero-install * fix(ci): preserve zero-install script owners * fix(ci): complete script migration follow-through * fix(release): keep stable closeout zero-install * fix(scripts): preserve standalone execution boundaries * fix(scripts): repair standalone loader boundaries * fix(scripts): normalize gateway observation ids * fix(scripts): keep Docker packager standalone * test(scripts): preserve rebase cleanup helpers * test(sessions): use tracked temp directory
798 lines
27 KiB
TypeScript
798 lines
27 KiB
TypeScript
#!/usr/bin/env node
|
|
|
|
// Summarizes OpenClaw performance source fixtures for reports.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { pathToFileURL } from "node:url";
|
|
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
|
|
type JsonObject = { [key: string]: JsonValue };
|
|
type JsonValue = boolean | number | string | null | JsonObject | JsonValue[];
|
|
type RequiredOption = { required?: boolean };
|
|
|
|
function isJsonValue(value: unknown): value is JsonValue {
|
|
if (value === null || ["boolean", "number", "string"].includes(typeof value)) {
|
|
return true;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.every(isJsonValue);
|
|
}
|
|
return isRecord(value) && Object.values(value).every(isJsonValue);
|
|
}
|
|
|
|
function parseJson(source: string): JsonValue {
|
|
const value: unknown = JSON.parse(source);
|
|
if (!isJsonValue(value)) {
|
|
throw new Error("parsed value is not JSON");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function valueAt(value: JsonValue | undefined, ...keys: string[]): JsonValue | undefined {
|
|
let current = value;
|
|
for (const key of keys) {
|
|
current = isJsonObject(current) ? current[key] : undefined;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function objectArray(value: JsonValue | undefined): JsonObject[] {
|
|
return Array.isArray(value) ? value.filter(isJsonObject) : [];
|
|
}
|
|
|
|
function readOptionValue(argv: string[], index: number, optionName: string): string {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("-")) {
|
|
throw new Error(`${optionName} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parseArgs(argv: string[]) {
|
|
const options: Record<"baselineSourceDir" | "sourceDir" | "output", string | null> = {
|
|
baselineSourceDir: null,
|
|
sourceDir: null,
|
|
output: null,
|
|
};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index] ?? "";
|
|
const readValue = () => {
|
|
const value = readOptionValue(argv, index, arg);
|
|
index += 1;
|
|
return value;
|
|
};
|
|
switch (arg) {
|
|
case "--source-dir":
|
|
options.sourceDir = path.resolve(readValue());
|
|
break;
|
|
case "--baseline-source-dir":
|
|
options.baselineSourceDir = path.resolve(readValue());
|
|
break;
|
|
case "--output":
|
|
options.output = path.resolve(readValue());
|
|
break;
|
|
case "--help":
|
|
printHelp();
|
|
process.exit(0);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
if (!options.sourceDir) {
|
|
throw new Error("--source-dir is required");
|
|
}
|
|
return { ...options, sourceDir: options.sourceDir };
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: node --import tsx scripts/openclaw-performance-source-summary.mts --source-dir <dir> [--baseline-source-dir <dir>] [--output <summary.md>]
|
|
|
|
Summarizes OpenClaw-native performance probe artifacts for CI reports.`);
|
|
}
|
|
|
|
function readJsonIfExists(filePath: string): JsonValue {
|
|
if (!fs.existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
return parseJson(fs.readFileSync(filePath, "utf8"));
|
|
}
|
|
|
|
function readRequiredJson(filePath: string, label: string): JsonValue {
|
|
if (!fs.existsSync(filePath)) {
|
|
throw new Error(`[source-performance] missing required ${label}: ${filePath}`);
|
|
}
|
|
return parseJson(fs.readFileSync(filePath, "utf8"));
|
|
}
|
|
|
|
function finiteNumber(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
}
|
|
|
|
function formatMs(value: unknown) {
|
|
return finiteNumber(value) ? `${value.toFixed(1)}ms` : "n/a";
|
|
}
|
|
|
|
function formatMb(value: unknown) {
|
|
return finiteNumber(value) ? `${value.toFixed(1)}MB` : "n/a";
|
|
}
|
|
|
|
function formatBytesAsMb(value: unknown) {
|
|
return finiteNumber(value) ? formatMb(value / 1024 / 1024) : "n/a";
|
|
}
|
|
|
|
function formatRatio(value: unknown) {
|
|
return finiteNumber(value) ? value.toFixed(3) : "n/a";
|
|
}
|
|
|
|
function metric(stats: JsonValue | undefined, key: "p50" | "p95" = "p50") {
|
|
const value = valueAt(stats, key);
|
|
return typeof value === "number" ? value : null;
|
|
}
|
|
|
|
function percentDelta(before: unknown, after: unknown) {
|
|
if (typeof before !== "number" || typeof after !== "number") {
|
|
return null;
|
|
}
|
|
if (before === 0) {
|
|
return after === 0 ? 0 : null;
|
|
}
|
|
return ((after - before) / before) * 100;
|
|
}
|
|
|
|
function formatDeltaMb(before: unknown, after: unknown) {
|
|
if (typeof before !== "number" || typeof after !== "number") {
|
|
return "n/a";
|
|
}
|
|
const delta = after - before;
|
|
const percent = percentDelta(before, after);
|
|
const sign = delta > 0 ? "+" : "";
|
|
const percentText = percent == null ? "new" : `${percent > 0 ? "+" : ""}${percent.toFixed(1)}%`;
|
|
return `${sign}${formatMb(delta)} (${percentText})`;
|
|
}
|
|
|
|
function memoryRisk(before: unknown, after: unknown) {
|
|
const percent = percentDelta(before, after);
|
|
const delta = typeof before === "number" && typeof after === "number" ? after - before : null;
|
|
if (percent == null || delta == null) {
|
|
return "n/a";
|
|
}
|
|
if (percent >= 20 && delta >= 10) {
|
|
return "watch";
|
|
}
|
|
if (percent <= -10 && delta <= -10) {
|
|
return "improved";
|
|
}
|
|
return "stable";
|
|
}
|
|
|
|
function escapeCell(value: unknown) {
|
|
return String(value).replaceAll("|", "\\|");
|
|
}
|
|
|
|
function table(headers: string[], rows: unknown[][]) {
|
|
if (rows.length === 0) {
|
|
return ["No data.", ""];
|
|
}
|
|
return [
|
|
`| ${headers.join(" | ")} |`,
|
|
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
...rows.map((row) => `| ${row.map((cell) => escapeCell(cell)).join(" | ")} |`),
|
|
"",
|
|
];
|
|
}
|
|
|
|
function validateMockHelloSummary(summary: JsonValue, filePath: string) {
|
|
const total = valueAt(summary, "counts", "total");
|
|
const passed = valueAt(summary, "counts", "passed");
|
|
const failed = valueAt(summary, "counts", "failed");
|
|
const scenarios = objectArray(valueAt(summary, "scenarios"));
|
|
if (
|
|
!isNonNegativeInteger(total) ||
|
|
!isNonNegativeInteger(passed) ||
|
|
!isNonNegativeInteger(failed) ||
|
|
total <= 0 ||
|
|
failed !== 0 ||
|
|
passed !== total
|
|
) {
|
|
throw new Error(`[source-performance] invalid mock hello summary counts: ${filePath}`);
|
|
}
|
|
if (scenarios.length !== total) {
|
|
throw new Error(`[source-performance] invalid mock hello scenario evidence: ${filePath}`);
|
|
}
|
|
const passedScenarios = scenarios.filter((scenario) => scenario.status === "pass").length;
|
|
const failedScenarios = scenarios.filter((scenario) => scenario.status === "fail").length;
|
|
const invalidScenario = scenarios.find((scenario) => {
|
|
const status = scenario.status;
|
|
return typeof status !== "string" || !["pass", "fail", "skip"].includes(status);
|
|
});
|
|
if (invalidScenario || passedScenarios !== passed || failedScenarios !== failed) {
|
|
throw new Error(`[source-performance] invalid mock hello scenario evidence: ${filePath}`);
|
|
}
|
|
const requiredMetrics = [
|
|
"wallMs",
|
|
"gatewayCpuCoreRatio",
|
|
"gatewayProcessRssStartBytes",
|
|
"gatewayProcessRssEndBytes",
|
|
"gatewayProcessRssDeltaBytes",
|
|
];
|
|
const missingMetric = requiredMetrics.find(
|
|
(key) => !finiteNumber(valueAt(summary, "metrics", key)),
|
|
);
|
|
if (missingMetric) {
|
|
throw new Error(`[source-performance] missing mock hello metric ${missingMetric}: ${filePath}`);
|
|
}
|
|
}
|
|
|
|
function isNonNegativeInteger(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
}
|
|
|
|
function loadMockHelloSummaries(sourceDir: string, { required = false }: RequiredOption = {}) {
|
|
const root = path.join(sourceDir, "mock-hello");
|
|
if (!fs.existsSync(root)) {
|
|
if (required) {
|
|
throw new Error(`[source-performance] missing required mock hello directory: ${root}`);
|
|
}
|
|
return [];
|
|
}
|
|
const summaries = fs
|
|
.readdirSync(root, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => ({
|
|
id: entry.name,
|
|
summaryPath: path.join(root, entry.name, "qa-suite-summary.json"),
|
|
}))
|
|
.filter((entry) => fs.existsSync(entry.summaryPath))
|
|
.map((entry) => ({
|
|
id: entry.id,
|
|
summary: parseJson(fs.readFileSync(entry.summaryPath, "utf8")),
|
|
summaryPath: entry.summaryPath,
|
|
}))
|
|
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
if (required && summaries.length === 0) {
|
|
throw new Error(`[source-performance] missing required mock hello summaries: ${root}`);
|
|
}
|
|
if (required) {
|
|
for (const entry of summaries) {
|
|
validateMockHelloSummary(entry.summary, entry.summaryPath);
|
|
}
|
|
}
|
|
return summaries.map(({ id, summary }) => ({ id, summary }));
|
|
}
|
|
|
|
type MockHelloEntry = ReturnType<typeof loadMockHelloSummaries>[number];
|
|
|
|
function validateStartupArtifact(startup: JsonValue, filePath: string) {
|
|
const results = objectArray(valueAt(startup, "results"));
|
|
if (results.length === 0) {
|
|
throw new Error(`[source-performance] missing gateway startup results: ${filePath}`);
|
|
}
|
|
for (const result of results) {
|
|
if (
|
|
!finiteNumber(valueAt(result, "summary", "readyzMs", "p50")) ||
|
|
!finiteNumber(valueAt(result, "summary", "maxRssMb", "p95")) ||
|
|
!finiteNumber(valueAt(result, "summary", "cpuCoreRatio", "p95"))
|
|
) {
|
|
const resultId = typeof result.id === "string" ? result.id : "unknown";
|
|
throw new Error(
|
|
`[source-performance] incomplete gateway startup metrics for ${resultId}: ${filePath}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateCliArtifact(cli: JsonValue, filePath: string) {
|
|
const cases = objectArray(valueAt(cli, "primary", "cases"));
|
|
if (cases.length === 0) {
|
|
throw new Error(`[source-performance] missing CLI startup cases: ${filePath}`);
|
|
}
|
|
for (const entry of cases) {
|
|
if (
|
|
!finiteNumber(valueAt(entry, "summary", "durationMs", "p50")) ||
|
|
!finiteNumber(valueAt(entry, "summary", "maxRssMb", "p95"))
|
|
) {
|
|
const entryId = typeof entry.id === "string" ? entry.id : "unknown";
|
|
throw new Error(
|
|
`[source-performance] incomplete CLI startup metrics for ${entryId}: ${filePath}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateExtensionMemoryArtifact(extensionMemory: JsonValue, filePath: string) {
|
|
const rows = objectArray(valueAt(extensionMemory, "topByDeltaMb"));
|
|
if (rows.length === 0) {
|
|
throw new Error(`[source-performance] missing extension memory rows: ${filePath}`);
|
|
}
|
|
if (
|
|
!finiteNumber(valueAt(extensionMemory, "baseline", "maxRssMb")) ||
|
|
!finiteNumber(valueAt(extensionMemory, "combined", "maxRssMb"))
|
|
) {
|
|
throw new Error(`[source-performance] incomplete extension memory context: ${filePath}`);
|
|
}
|
|
for (const entry of rows) {
|
|
if (!finiteNumber(entry.maxRssMb) || !finiteNumber(entry.deltaFromBaselineMb)) {
|
|
const entryDir = typeof entry.dir === "string" ? entry.dir : "unknown";
|
|
throw new Error(
|
|
`[source-performance] incomplete extension memory metrics for ${entryDir}: ${filePath}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateSqlitePerfArtifact(sqlitePerf: JsonValue, filePath: string) {
|
|
if (valueAt(sqlitePerf, "profile") !== "smoke") {
|
|
throw new Error(`[source-performance] invalid SQLite perf profile: ${filePath}`);
|
|
}
|
|
if (valueAt(sqlitePerf, "integrity", "state") !== "ok") {
|
|
throw new Error(`[source-performance] SQLite integrity check did not pass: ${filePath}`);
|
|
}
|
|
const agentIntegrity = valueAt(sqlitePerf, "integrity", "agent");
|
|
if (
|
|
!Array.isArray(agentIntegrity) ||
|
|
agentIntegrity.length === 0 ||
|
|
agentIntegrity.some((entry) => entry !== "ok")
|
|
) {
|
|
throw new Error(`[source-performance] SQLite agent integrity check did not pass: ${filePath}`);
|
|
}
|
|
const stateRows = valueAt(sqlitePerf, "rows", "stateRows");
|
|
const agentRows = valueAt(sqlitePerf, "rows", "agentCacheEntries");
|
|
const queries = objectArray(valueAt(sqlitePerf, "queries"));
|
|
if (
|
|
!isNonNegativeInteger(stateRows) ||
|
|
stateRows <= 0 ||
|
|
!isNonNegativeInteger(agentRows) ||
|
|
agentRows <= 0 ||
|
|
!finiteNumber(valueAt(sqlitePerf, "timingsMs", "total")) ||
|
|
!finiteNumber(valueAt(sqlitePerf, "walBytes", "stateBefore")) ||
|
|
valueAt(sqlitePerf, "walBytes", "stateAfter") !== 0 ||
|
|
queries.length === 0
|
|
) {
|
|
throw new Error(`[source-performance] incomplete SQLite perf metrics: ${filePath}`);
|
|
}
|
|
for (const entry of queries) {
|
|
if (!finiteNumber(entry.p50Ms) || !finiteNumber(entry.p95Ms) || !finiteNumber(entry.rows)) {
|
|
throw new Error(`[source-performance] incomplete SQLite query metrics: ${filePath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateGatewaySummaryArtifact(gatewaySummary: JsonValue, filePath: string) {
|
|
if (!Array.isArray(valueAt(gatewaySummary, "observations"))) {
|
|
throw new Error(`[source-performance] missing gateway observation summary: ${filePath}`);
|
|
}
|
|
}
|
|
|
|
function loadSourceArtifacts(sourceDir: string | null, { required = false }: RequiredOption = {}) {
|
|
if (!sourceDir || !fs.existsSync(sourceDir)) {
|
|
if (required) {
|
|
throw new Error(`[source-performance] missing required source dir: ${sourceDir}`);
|
|
}
|
|
return null;
|
|
}
|
|
const stat = fs.statSync(sourceDir);
|
|
if (!stat.isDirectory()) {
|
|
throw new Error(`[source-performance] source path is not a directory: ${sourceDir}`);
|
|
}
|
|
const startupPath = path.join(sourceDir, "gateway-cpu", "gateway-startup-bench.json");
|
|
const cliPath = path.join(sourceDir, "cli-startup.json");
|
|
const extensionMemoryPath = path.join(sourceDir, "extension-memory.json");
|
|
const sqlitePerfPath = path.join(sourceDir, "sqlite-perf-smoke.json");
|
|
const artifacts = {
|
|
startup: required
|
|
? readRequiredJson(startupPath, "gateway startup artifact")
|
|
: readJsonIfExists(startupPath),
|
|
cli: required ? readRequiredJson(cliPath, "CLI startup artifact") : readJsonIfExists(cliPath),
|
|
extensionMemory: required
|
|
? readRequiredJson(extensionMemoryPath, "extension memory artifact")
|
|
: readJsonIfExists(extensionMemoryPath),
|
|
sqlitePerf: readJsonIfExists(sqlitePerfPath),
|
|
mockHelloSummaries: loadMockHelloSummaries(sourceDir, { required }),
|
|
};
|
|
if (required) {
|
|
validateStartupArtifact(artifacts.startup, startupPath);
|
|
validateCliArtifact(artifacts.cli, cliPath);
|
|
validateExtensionMemoryArtifact(artifacts.extensionMemory, extensionMemoryPath);
|
|
if (artifacts.sqlitePerf) {
|
|
validateSqlitePerfArtifact(artifacts.sqlitePerf, sqlitePerfPath);
|
|
}
|
|
}
|
|
return artifacts;
|
|
}
|
|
|
|
type SourceArtifacts = NonNullable<ReturnType<typeof loadSourceArtifacts>>;
|
|
|
|
function buildStartupRows(startup: JsonValue) {
|
|
return objectArray(valueAt(startup, "results")).map((result) => [
|
|
result.id ?? "unknown",
|
|
result.name ?? result.id ?? "unknown",
|
|
formatMs(metric(valueAt(result, "summary", "readyzMs"))),
|
|
formatMs(metric(valueAt(result, "summary", "readyzMs"), "p95")),
|
|
formatMs(metric(valueAt(result, "summary", "healthzMs"))),
|
|
formatMs(metric(valueAt(result, "summary", "httpListenLogMs"))),
|
|
formatMs(metric(valueAt(result, "summary", "gatewayReadyLogMs"))),
|
|
formatMs(metric(valueAt(result, "summary", "firstOutputMs"))),
|
|
formatMb(metric(valueAt(result, "summary", "maxRssMb"), "p95")),
|
|
formatRatio(metric(valueAt(result, "summary", "cpuCoreRatio"), "p95")),
|
|
]);
|
|
}
|
|
|
|
function buildTraceRows(startup: JsonValue) {
|
|
const rows: unknown[][] = [];
|
|
for (const result of objectArray(valueAt(startup, "results"))) {
|
|
const trace = valueAt(result, "summary", "startupTrace");
|
|
const traceEntries = (isJsonObject(trace) ? Object.entries(trace) : [])
|
|
.filter(([name, stats]) => isStartupTraceDuration(name) && metric(stats) !== null)
|
|
.toSorted((a, b) => (metric(b[1]) ?? 0) - (metric(a[1]) ?? 0))
|
|
.slice(0, 5);
|
|
for (const [name, stats] of traceEntries) {
|
|
rows.push([
|
|
result.id ?? "unknown",
|
|
name,
|
|
formatMs(metric(stats)),
|
|
formatMs(metric(stats, "p95")),
|
|
]);
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function isStartupTraceDuration(name: string) {
|
|
if (name.endsWith(".total") || name.startsWith("memory.")) {
|
|
return false;
|
|
}
|
|
const metricName = name.slice(name.lastIndexOf(".") + 1);
|
|
return !metricName.endsWith("Count") && !metricName.endsWith("Mb");
|
|
}
|
|
|
|
function buildMockHelloRows(summaries: MockHelloEntry[]) {
|
|
return summaries.map(({ id, summary }) => {
|
|
const failed = valueAt(summary, "counts", "failed");
|
|
const countInfo = valueAt(summary, "counts");
|
|
const status = typeof failed === "number" && failed > 0 ? "fail" : "pass";
|
|
const passed = isJsonObject(countInfo) ? countInfo.passed : undefined;
|
|
const total = isJsonObject(countInfo) ? countInfo.total : undefined;
|
|
const counts =
|
|
isNonNegativeInteger(passed) && isNonNegativeInteger(total) ? `${passed}/${total}` : "n/a";
|
|
return [
|
|
id,
|
|
status,
|
|
counts,
|
|
formatMs(valueAt(summary, "metrics", "wallMs")),
|
|
formatRatio(valueAt(summary, "metrics", "gatewayCpuCoreRatio")),
|
|
formatBytesAsMb(valueAt(summary, "metrics", "gatewayProcessRssStartBytes")),
|
|
formatBytesAsMb(valueAt(summary, "metrics", "gatewayProcessRssEndBytes")),
|
|
formatBytesAsMb(valueAt(summary, "metrics", "gatewayProcessRssDeltaBytes")),
|
|
valueAt(summary, "run", "primaryModel") ?? "n/a",
|
|
];
|
|
});
|
|
}
|
|
|
|
function buildCliRows(cli: JsonValue) {
|
|
return objectArray(valueAt(cli, "primary", "cases")).map((commandCase) => [
|
|
commandCase.id ?? "unknown",
|
|
commandCase.name ?? commandCase.id ?? "unknown",
|
|
formatMs(valueAt(commandCase, "summary", "durationMs", "p50")),
|
|
formatMs(valueAt(commandCase, "summary", "durationMs", "p95")),
|
|
formatMb(valueAt(commandCase, "summary", "maxRssMb", "p95")),
|
|
formatExitSummary(valueAt(commandCase, "summary", "exitSummary")),
|
|
]);
|
|
}
|
|
|
|
function buildStartupMemoryDeltaRows(current: JsonValue, baseline: JsonValue) {
|
|
const baselineById = new Map(
|
|
objectArray(valueAt(baseline, "results")).map((result) => [result.id, result]),
|
|
);
|
|
return objectArray(valueAt(current, "results"))
|
|
.map((result) => {
|
|
const before = baselineById.get(result.id);
|
|
if (!before) {
|
|
return null;
|
|
}
|
|
const beforeRss = metric(valueAt(before, "summary", "maxRssMb"), "p95");
|
|
const afterRss = metric(valueAt(result, "summary", "maxRssMb"), "p95");
|
|
const beforeReadyHeap = metric(
|
|
valueAt(before, "summary", "startupTrace", "memory.ready.heapUsedMb"),
|
|
"p95",
|
|
);
|
|
const afterReadyHeap = metric(
|
|
valueAt(result, "summary", "startupTrace", "memory.ready.heapUsedMb"),
|
|
"p95",
|
|
);
|
|
return [
|
|
"gateway boot",
|
|
result.id ?? "unknown",
|
|
formatMb(beforeRss),
|
|
formatMb(afterRss),
|
|
formatDeltaMb(beforeRss, afterRss),
|
|
formatDeltaMb(beforeReadyHeap, afterReadyHeap),
|
|
memoryRisk(beforeRss, afterRss),
|
|
];
|
|
})
|
|
.filter((row) => row !== null);
|
|
}
|
|
|
|
function buildCliMemoryDeltaRows(current: JsonValue, baseline: JsonValue) {
|
|
const baselineById = new Map(
|
|
objectArray(valueAt(baseline, "primary", "cases")).map((entry) => [entry.id, entry]),
|
|
);
|
|
return objectArray(valueAt(current, "primary", "cases"))
|
|
.map((entry) => {
|
|
const before = baselineById.get(entry.id);
|
|
if (!before) {
|
|
return null;
|
|
}
|
|
const beforeRss = metric(valueAt(before, "summary", "maxRssMb"), "p95");
|
|
const afterRss = metric(valueAt(entry, "summary", "maxRssMb"), "p95");
|
|
return [
|
|
"cli",
|
|
entry.id ?? "unknown",
|
|
formatMb(beforeRss),
|
|
formatMb(afterRss),
|
|
formatDeltaMb(beforeRss, afterRss),
|
|
"n/a",
|
|
memoryRisk(beforeRss, afterRss),
|
|
];
|
|
})
|
|
.filter((row) => row !== null);
|
|
}
|
|
|
|
function average(values: unknown[]) {
|
|
const numeric = values.filter(finiteNumber);
|
|
if (numeric.length === 0) {
|
|
return null;
|
|
}
|
|
return numeric.reduce((sum, value) => sum + value, 0) / numeric.length;
|
|
}
|
|
|
|
function buildMockHelloMemoryDeltaRows(current: MockHelloEntry[], baseline: MockHelloEntry[]) {
|
|
const beforeDeltaBytes = average(
|
|
baseline.map((entry) => valueAt(entry.summary, "metrics", "gatewayProcessRssDeltaBytes")),
|
|
);
|
|
const afterDeltaBytes = average(
|
|
current.map((entry) => valueAt(entry.summary, "metrics", "gatewayProcessRssDeltaBytes")),
|
|
);
|
|
if (beforeDeltaBytes == null || afterDeltaBytes == null) {
|
|
return [];
|
|
}
|
|
const beforeDelta = beforeDeltaBytes / 1024 / 1024;
|
|
const afterDelta = afterDeltaBytes / 1024 / 1024;
|
|
return [
|
|
[
|
|
"mock hello",
|
|
"gateway RSS delta avg",
|
|
formatMb(beforeDelta),
|
|
formatMb(afterDelta),
|
|
formatDeltaMb(beforeDelta, afterDelta),
|
|
"n/a",
|
|
memoryRisk(beforeDelta, afterDelta),
|
|
],
|
|
];
|
|
}
|
|
|
|
function buildExtensionMemoryRows(extensionMemory: JsonValue) {
|
|
return objectArray(valueAt(extensionMemory, "topByDeltaMb"))
|
|
.slice(0, 10)
|
|
.map((entry) => [
|
|
entry.dir ?? "unknown",
|
|
formatMb(entry.maxRssMb),
|
|
formatMb(entry.deltaFromBaselineMb),
|
|
entry.status ?? "unknown",
|
|
]);
|
|
}
|
|
|
|
function buildExtensionMemoryContextRows(extensionMemory: JsonValue) {
|
|
const baselineMb = valueAt(extensionMemory, "baseline", "maxRssMb");
|
|
const combinedMb = valueAt(extensionMemory, "combined", "maxRssMb");
|
|
const totalEntries = valueAt(extensionMemory, "counts", "totalEntries");
|
|
return [
|
|
[
|
|
"empty Node process",
|
|
formatMb(baselineMb),
|
|
formatMb(0),
|
|
valueAt(extensionMemory, "baseline", "status") ?? "unknown",
|
|
],
|
|
[
|
|
finiteNumber(totalEntries)
|
|
? `all ${totalEntries} bundled plugins`
|
|
: "all selected bundled plugins",
|
|
formatMb(combinedMb),
|
|
finiteNumber(baselineMb) && finiteNumber(combinedMb)
|
|
? formatMb(combinedMb - baselineMb)
|
|
: "n/a",
|
|
valueAt(extensionMemory, "combined", "status") ?? "unknown",
|
|
],
|
|
];
|
|
}
|
|
|
|
function buildSqlitePerfRows(sqlitePerf: JsonValue) {
|
|
if (!sqlitePerf) {
|
|
return [];
|
|
}
|
|
const queryP95Values = objectArray(valueAt(sqlitePerf, "queries"))
|
|
.map((entry) => entry.p95Ms)
|
|
.filter(finiteNumber);
|
|
const maxQueryP95 = Math.max(...queryP95Values);
|
|
const stateRows = valueAt(sqlitePerf, "rows", "stateRows");
|
|
const agentRows = valueAt(sqlitePerf, "rows", "agentCacheEntries");
|
|
return [
|
|
[
|
|
valueAt(sqlitePerf, "profile") ?? "unknown",
|
|
isNonNegativeInteger(stateRows) ? stateRows : "n/a",
|
|
isNonNegativeInteger(agentRows) ? agentRows : "n/a",
|
|
valueAt(sqlitePerf, "integrity", "state") ?? "n/a",
|
|
formatBytesAsMb(valueAt(sqlitePerf, "walBytes", "stateBefore")),
|
|
formatBytesAsMb(valueAt(sqlitePerf, "walBytes", "stateAfter")),
|
|
formatMs(maxQueryP95),
|
|
formatMs(valueAt(sqlitePerf, "timingsMs", "total")),
|
|
],
|
|
];
|
|
}
|
|
|
|
function buildMemoryDeltaRows(current: SourceArtifacts, baseline: SourceArtifacts | null) {
|
|
if (!baseline) {
|
|
return [];
|
|
}
|
|
return [
|
|
...buildStartupMemoryDeltaRows(current.startup, baseline.startup),
|
|
...buildCliMemoryDeltaRows(current.cli, baseline.cli),
|
|
...buildMockHelloMemoryDeltaRows(current.mockHelloSummaries, baseline.mockHelloSummaries),
|
|
];
|
|
}
|
|
|
|
function formatExitSummary(value: unknown) {
|
|
if (typeof value !== "string" || !value) {
|
|
return "n/a";
|
|
}
|
|
return value.replaceAll(/\b(code:(?:null|-?\d+)|signal:[^,\s]+)x(\d+)\b/g, "$1 x$2");
|
|
}
|
|
|
|
function buildObservationRows(summary: JsonValue) {
|
|
return objectArray(valueAt(summary, "observations")).map((observation) => [
|
|
observation.kind ?? "unknown",
|
|
observation.id ?? "unknown",
|
|
formatRatio(observation.cpuCoreRatio ?? observation.cpuCoreRatioMax),
|
|
formatMs(observation.wallMs ?? observation.wallMsMax),
|
|
]);
|
|
}
|
|
|
|
export function buildMarkdown(sourceDir: string, baselineSourceDir: string | null) {
|
|
const current = loadSourceArtifacts(sourceDir, { required: true });
|
|
const baseline = loadSourceArtifacts(baselineSourceDir ?? null);
|
|
const gatewaySummaryPath = path.join(sourceDir, "gateway-cpu", "summary.json");
|
|
const gatewaySummary = readRequiredJson(gatewaySummaryPath, "gateway observation summary");
|
|
if (!current) {
|
|
throw new Error(`[source-performance] missing required source dir: ${sourceDir}`);
|
|
}
|
|
validateGatewaySummaryArtifact(gatewaySummary, gatewaySummaryPath);
|
|
const memoryDeltaRows = buildMemoryDeltaRows(current, baseline);
|
|
|
|
const lines = [
|
|
"# OpenClaw Source Performance",
|
|
"",
|
|
`Generated: ${new Date().toISOString()}`,
|
|
"",
|
|
"## Gateway Boot",
|
|
"",
|
|
...table(
|
|
[
|
|
"case",
|
|
"name",
|
|
"readyz p50",
|
|
"readyz p95",
|
|
"healthz p50",
|
|
"http listen p50",
|
|
"gateway ready p50",
|
|
"first output p50",
|
|
"RSS p95",
|
|
"CPU core p95",
|
|
],
|
|
buildStartupRows(current.startup),
|
|
),
|
|
"## Memory Trend",
|
|
"",
|
|
baseline
|
|
? "Compared with the latest published mock-provider source probe for this tested ref."
|
|
: "No published source baseline was available for this tested ref.",
|
|
"",
|
|
...table(
|
|
[
|
|
"surface",
|
|
"case",
|
|
"baseline RSS p95",
|
|
"current RSS p95",
|
|
"RSS delta",
|
|
"heap delta",
|
|
"state",
|
|
],
|
|
memoryDeltaRows,
|
|
),
|
|
"## Bundled Plugin Import Memory",
|
|
"",
|
|
"Per-plugin rows are isolated cold imports and are not additive. The combined row measures all selected bundled-plugin entrypoints in one process.",
|
|
"",
|
|
...table(
|
|
["measurement", "max RSS", "delta from empty process", "status"],
|
|
buildExtensionMemoryContextRows(current.extensionMemory),
|
|
),
|
|
...table(
|
|
["plugin", "isolated max RSS", "isolated delta from empty process", "status"],
|
|
buildExtensionMemoryRows(current.extensionMemory),
|
|
),
|
|
"## Startup Hotspots",
|
|
"",
|
|
...table(["case", "phase", "p50", "p95"], buildTraceRows(current.startup)),
|
|
"## Fake Model Hello Loops",
|
|
"",
|
|
...table(
|
|
[
|
|
"run",
|
|
"status",
|
|
"pass",
|
|
"wall",
|
|
"gateway CPU core",
|
|
"RSS start",
|
|
"RSS end",
|
|
"RSS delta",
|
|
"model",
|
|
],
|
|
buildMockHelloRows(current.mockHelloSummaries),
|
|
),
|
|
"## CLI Against Booted Gateway",
|
|
"",
|
|
...table(
|
|
["case", "command", "duration p50", "duration p95", "RSS p95", "exits"],
|
|
buildCliRows(current.cli),
|
|
),
|
|
"## SQLite State Smoke",
|
|
"",
|
|
...table(
|
|
[
|
|
"profile",
|
|
"state rows",
|
|
"agent rows",
|
|
"integrity",
|
|
"WAL before",
|
|
"WAL after",
|
|
"query p95 max",
|
|
"total",
|
|
],
|
|
buildSqlitePerfRows(current.sqlitePerf),
|
|
),
|
|
"## Observations",
|
|
"",
|
|
...table(["kind", "id", "CPU core", "wall"], buildObservationRows(gatewaySummary)),
|
|
];
|
|
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const markdown = buildMarkdown(options.sourceDir, options.baselineSourceDir);
|
|
if (options.output) {
|
|
fs.mkdirSync(path.dirname(options.output), { recursive: true });
|
|
fs.writeFileSync(options.output, markdown, "utf8");
|
|
} else {
|
|
process.stdout.write(markdown);
|
|
}
|
|
}
|
|
|
|
function isCliEntry() {
|
|
const cliArg = process.argv[1];
|
|
return cliArg ? import.meta.url === pathToFileURL(cliArg).href : false;
|
|
}
|
|
|
|
if (isCliEntry()) {
|
|
main().catch((error: unknown) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|