mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 08:31:49 -06:00
c5d00cb47d
* fix(ui): keep task transcripts in the task sidebar * refactor(ui): split background task rail rendering * fix(ui): reset stale task rail views * fix(sessions): honor context usage provenance Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com> * fix(qa): split suite runtime agent process below line cap --------- Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
// Qa Lab plugin module runs CLI processes and parses their structured output.
|
|
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import path from "node:path";
|
|
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
|
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
import {
|
|
appendQaChildOutput,
|
|
appendQaChildOutputTail,
|
|
createQaChildOutputCapture,
|
|
createQaChildOutputTail,
|
|
formatQaChildOutputTail,
|
|
QA_CHILD_STDOUT_MAX_BYTES,
|
|
readQaChildOutput,
|
|
} from "./child-output.js";
|
|
import { QaSuiteInfraError } from "./errors.js";
|
|
import { resolveQaNodeExecPath } from "./node-exec.js";
|
|
import { createQaPosixCommandSettlement } from "./posix-command-settlement.js";
|
|
import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
|
|
import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js";
|
|
|
|
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1B\[[0-?]*[ -/]*[@-~]`, "g");
|
|
|
|
function stripAnsiCodes(text: string) {
|
|
return text.replace(ANSI_ESCAPE_PATTERN, "");
|
|
}
|
|
|
|
function findBalancedJsonEnd(text: string, startIndex: number) {
|
|
const opening = text[startIndex];
|
|
const firstClosing = opening === "{" ? "}" : opening === "[" ? "]" : "";
|
|
if (!firstClosing) {
|
|
return -1;
|
|
}
|
|
|
|
const stack = [firstClosing];
|
|
let inString = false;
|
|
let escaping = false;
|
|
for (let index = startIndex + 1; index < text.length; index += 1) {
|
|
const char = text[index];
|
|
if (inString) {
|
|
if (escaping) {
|
|
escaping = false;
|
|
} else if (char === "\\") {
|
|
escaping = true;
|
|
} else if (char === '"') {
|
|
inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (char === '"') {
|
|
inString = true;
|
|
} else if (char === "{" || char === "[") {
|
|
stack.push(char === "{" ? "}" : "]");
|
|
} else if (char === "}" || char === "]") {
|
|
if (stack.at(-1) !== char) {
|
|
return -1;
|
|
}
|
|
stack.pop();
|
|
if (stack.length === 0) {
|
|
return index;
|
|
}
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function parseBalancedJsonPayloadStart(text: string) {
|
|
const trimmedStart = text.search(/\S/u);
|
|
if (trimmedStart < 0) {
|
|
return undefined;
|
|
}
|
|
const char = text[trimmedStart];
|
|
if (char !== "{" && char !== "[") {
|
|
return undefined;
|
|
}
|
|
const end = findBalancedJsonEnd(text, trimmedStart);
|
|
if (end <= trimmedStart) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return JSON.parse(text.slice(trimmedStart, end + 1)) as unknown;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function isJsonRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
function isStructuredDiagnosticJson(value: unknown) {
|
|
if (!isJsonRecord(value)) {
|
|
return false;
|
|
}
|
|
const level = value.level ?? value.logLevel ?? value.severity;
|
|
if (typeof level !== "string") {
|
|
return false;
|
|
}
|
|
return (
|
|
typeof value.message === "string" ||
|
|
typeof value.msg === "string" ||
|
|
typeof value.time === "string" ||
|
|
typeof value.timestamp === "string"
|
|
);
|
|
}
|
|
|
|
function isMemorySearchJsonPayload(value: unknown) {
|
|
return isJsonRecord(value) && Array.isArray(value.results);
|
|
}
|
|
|
|
function isMemoryStatusJsonPayload(value: unknown) {
|
|
if (Array.isArray(value)) {
|
|
return true;
|
|
}
|
|
return isJsonRecord(value) && value.command === "memory" && value.subcommand === "status";
|
|
}
|
|
|
|
function resolveQaCliJsonPayloadMatcher(args: readonly string[]) {
|
|
if (!args.includes("--json")) {
|
|
return undefined;
|
|
}
|
|
if (args[0] === "memory" && args[1] === "search") {
|
|
return isMemorySearchJsonPayload;
|
|
}
|
|
if (args[0] === "memory" && args[1] === "status") {
|
|
return isMemoryStatusJsonPayload;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseQaCliJsonOutput(text: string, args: readonly string[]) {
|
|
const cleaned = stripAnsiCodes(text).trim();
|
|
if (!cleaned) {
|
|
return {};
|
|
}
|
|
const matchesExpectedPayload = resolveQaCliJsonPayloadMatcher(args);
|
|
try {
|
|
return JSON.parse(cleaned) as unknown;
|
|
} catch {
|
|
// Some startup repair logs are emitted on stdout before command JSON.
|
|
const lines = cleaned.split(/\r?\n/);
|
|
const candidates: unknown[] = [];
|
|
for (const [index, line] of lines.entries()) {
|
|
const candidate = line.trimStart();
|
|
if (candidate !== line || (!candidate.startsWith("{") && !candidate.startsWith("["))) {
|
|
continue;
|
|
}
|
|
const jsonTail = lines.slice(index).join("\n");
|
|
try {
|
|
candidates.push(JSON.parse(jsonTail) as unknown);
|
|
} catch {
|
|
const balanced = parseBalancedJsonPayloadStart(jsonTail);
|
|
if (balanced !== undefined) {
|
|
candidates.push(balanced);
|
|
}
|
|
}
|
|
}
|
|
const expectedPayload = candidates.find((value) => matchesExpectedPayload?.(value) === true);
|
|
if (expectedPayload !== undefined) {
|
|
return expectedPayload;
|
|
}
|
|
const payload = candidates.toReversed().find((value) => !isStructuredDiagnosticJson(value));
|
|
if (payload !== undefined) {
|
|
return payload;
|
|
}
|
|
const diagnosticOnly = candidates.at(-1);
|
|
if (diagnosticOnly !== undefined) {
|
|
return diagnosticOnly;
|
|
}
|
|
|
|
// Keep a line-oriented fallback for compact payloads followed by diagnostics.
|
|
for (const line of lines.toReversed()) {
|
|
const candidate = line.trim();
|
|
if (!candidate.startsWith("{") && !candidate.startsWith("[")) {
|
|
continue;
|
|
}
|
|
try {
|
|
return JSON.parse(candidate) as unknown;
|
|
} catch {
|
|
// Keep looking for the actual payload line.
|
|
}
|
|
}
|
|
throw new Error(`qa cli returned non-JSON stdout: ${truncateUtf16Safe(cleaned, 240)}`);
|
|
}
|
|
}
|
|
|
|
function killQaCliWindowsProcessTree(child: Pick<ChildProcessWithoutNullStreams, "kill" | "pid">) {
|
|
if (child.pid) {
|
|
const result = spawnSync(
|
|
resolveQaWindowsSystem32ExePath("taskkill.exe"),
|
|
["/PID", String(child.pid), "/T", "/F"],
|
|
{
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
timeout: 5_000,
|
|
},
|
|
);
|
|
if (!result.error && result.status === 0) {
|
|
return;
|
|
}
|
|
}
|
|
child.kill("SIGKILL");
|
|
}
|
|
|
|
async function runQaCli(
|
|
env: Pick<
|
|
QaSuiteRuntimeEnv,
|
|
"gateway" | "repoRoot" | "primaryModel" | "alternateModel" | "providerMode"
|
|
>,
|
|
args: string[],
|
|
opts?: { timeoutMs?: number; json?: boolean; env?: NodeJS.ProcessEnv },
|
|
) {
|
|
const stdout = createQaChildOutputCapture();
|
|
const stdoutTail = createQaChildOutputTail();
|
|
const stderr = createQaChildOutputTail();
|
|
const distEntryPath = path.join(env.repoRoot, "dist", "index.js");
|
|
const nodeExecPath = await resolveQaNodeExecPath();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn(nodeExecPath, [distEntryPath, ...args], {
|
|
cwd: env.gateway.tempRoot,
|
|
env: {
|
|
...env.gateway.runtimeEnv,
|
|
...opts?.env,
|
|
},
|
|
detached: process.platform !== "win32",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const timeoutMs = resolveTimerTimeoutMs(opts?.timeoutMs, 60_000);
|
|
const rejectTimeout = () => {
|
|
const stdoutText = formatQaChildOutputTail(stdoutTail, "qa cli stdout");
|
|
const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr");
|
|
const diagnostics = [
|
|
stdoutText ? `stdout:\n${stdoutText}` : "",
|
|
stderrText ? `stderr:\n${stderrText}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
return new QaSuiteInfraError(
|
|
"qa_cli_timeout",
|
|
`qa cli timed out: openclaw ${args.join(" ")}${diagnostics ? `\n${diagnostics}` : ""}`,
|
|
);
|
|
};
|
|
const getExitError = (code: number | null) => {
|
|
if (code === 0) {
|
|
if (stdout.exceeded) {
|
|
return new Error(
|
|
`qa cli stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`,
|
|
);
|
|
}
|
|
return undefined;
|
|
}
|
|
const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr");
|
|
return new Error(`qa cli failed (${code ?? "unknown"}): ${stderrText}`);
|
|
};
|
|
if (process.platform !== "win32") {
|
|
createQaPosixCommandSettlement({
|
|
child,
|
|
settlementFailureMessage: "qa cli settlement failed",
|
|
executionTimeoutMs: timeoutMs,
|
|
forceKillAfterMs: 0,
|
|
initialSignal: "SIGKILL",
|
|
onSettled: (outcome) => {
|
|
const primary = outcome.primary;
|
|
const primaryError =
|
|
primary.type === "spawn-error" || primary.type === "stream-error"
|
|
? primary.error
|
|
: primary.type === "timeout"
|
|
? rejectTimeout()
|
|
: getExitError(primary.type === "exit" ? primary.exitCode : 1);
|
|
if (outcome.settlementFailure) {
|
|
reject(
|
|
primaryError
|
|
? new AggregateError(
|
|
[primaryError, outcome.settlementFailure],
|
|
"qa cli command and settlement failed",
|
|
)
|
|
: outcome.settlementFailure,
|
|
);
|
|
return;
|
|
}
|
|
if (primaryError) {
|
|
reject(primaryError);
|
|
return;
|
|
}
|
|
resolve();
|
|
},
|
|
onStderrData: (chunk) => appendQaChildOutputTail(stderr, chunk),
|
|
onStdoutData: (chunk) => {
|
|
appendQaChildOutput(stdout, chunk);
|
|
appendQaChildOutputTail(stdoutTail, chunk);
|
|
},
|
|
processGroupId: child.pid,
|
|
verifyAfterMs: 500,
|
|
});
|
|
return;
|
|
}
|
|
const timeout = setTimeout(() => {
|
|
killQaCliWindowsProcessTree(child);
|
|
reject(rejectTimeout());
|
|
}, timeoutMs);
|
|
child.stdout.on("data", (chunk) => {
|
|
appendQaChildOutput(stdout, chunk);
|
|
appendQaChildOutputTail(stdoutTail, chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk));
|
|
child.once("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
child.once("close", (code) => {
|
|
clearTimeout(timeout);
|
|
const error = getExitError(code);
|
|
return error ? reject(error) : resolve();
|
|
});
|
|
});
|
|
const text = readQaChildOutput(stdout).trim();
|
|
if (!opts?.json) {
|
|
return text;
|
|
}
|
|
return parseQaCliJsonOutput(text, args);
|
|
}
|
|
|
|
export { runQaCli };
|