mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 17:54:32 +00:00
refactor: extract qmd process runner
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import { spawn } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -8,11 +7,12 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { writeFileWithinRoot } from "../infra/fs-safe.js";
|
import { writeFileWithinRoot } from "../infra/fs-safe.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import {
|
|
||||||
materializeWindowsSpawnProgram,
|
|
||||||
resolveWindowsSpawnProgram,
|
|
||||||
} from "../plugin-sdk/windows-spawn.js";
|
|
||||||
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
|
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
|
||||||
|
import {
|
||||||
|
isWindowsCommandShimEinval,
|
||||||
|
resolveCliSpawnInvocation,
|
||||||
|
runCliCommand,
|
||||||
|
} from "./qmd-process.js";
|
||||||
import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js";
|
import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js";
|
||||||
import {
|
import {
|
||||||
listSessionFilesForAgent,
|
listSessionFilesForAgent,
|
||||||
@@ -51,53 +51,6 @@ const QMD_BM25_HAN_KEYWORD_LIMIT = 12;
|
|||||||
|
|
||||||
let qmdEmbedQueueTail: Promise<void> = Promise.resolve();
|
let qmdEmbedQueueTail: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
function resolveWindowsCommandShim(command: string): string {
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
const trimmed = command.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
const ext = path.extname(trimmed).toLowerCase();
|
|
||||||
if (ext === ".cmd" || ext === ".exe" || ext === ".bat") {
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
const base = path.basename(trimmed).toLowerCase();
|
|
||||||
if (base === "qmd" || base === "mcporter") {
|
|
||||||
return `${trimmed}.cmd`;
|
|
||||||
}
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSpawnInvocation(params: {
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
env: NodeJS.ProcessEnv;
|
|
||||||
packageName: string;
|
|
||||||
}) {
|
|
||||||
const program = resolveWindowsSpawnProgram({
|
|
||||||
command: resolveWindowsCommandShim(params.command),
|
|
||||||
platform: process.platform,
|
|
||||||
env: params.env,
|
|
||||||
execPath: process.execPath,
|
|
||||||
packageName: params.packageName,
|
|
||||||
allowShellFallback: true,
|
|
||||||
});
|
|
||||||
return materializeWindowsSpawnProgram(program, params.args);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWindowsCmdSpawnEinval(err: unknown, command: string): boolean {
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const errno = err as NodeJS.ErrnoException | undefined;
|
|
||||||
if (errno?.code !== "EINVAL") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return /(^|[\\/])mcporter\.cmd$/i.test(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasHanScript(value: string): boolean {
|
function hasHanScript(value: string): boolean {
|
||||||
return HAN_SCRIPT_RE.test(value);
|
return HAN_SCRIPT_RE.test(value);
|
||||||
}
|
}
|
||||||
@@ -1235,70 +1188,20 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
args: string[],
|
args: string[],
|
||||||
opts?: { timeoutMs?: number; discardOutput?: boolean },
|
opts?: { timeoutMs?: number; discardOutput?: boolean },
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await runCliCommand({
|
||||||
const spawnInvocation = resolveSpawnInvocation({
|
commandSummary: `qmd ${args.join(" ")}`,
|
||||||
|
spawnInvocation: resolveCliSpawnInvocation({
|
||||||
command: this.qmd.command,
|
command: this.qmd.command,
|
||||||
args,
|
args,
|
||||||
env: this.env,
|
env: this.env,
|
||||||
packageName: "qmd",
|
packageName: "qmd",
|
||||||
});
|
}),
|
||||||
const child = spawn(spawnInvocation.command, spawnInvocation.argv, {
|
env: this.env,
|
||||||
env: this.env,
|
cwd: this.workspaceDir,
|
||||||
cwd: this.workspaceDir,
|
timeoutMs: opts?.timeoutMs,
|
||||||
shell: spawnInvocation.shell,
|
maxOutputChars: this.maxQmdOutputChars,
|
||||||
windowsHide: spawnInvocation.windowsHide,
|
// Large `qmd update` runs can easily exceed the output cap; keep only stderr.
|
||||||
});
|
discardStdout: opts?.discardOutput,
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
let stdoutTruncated = false;
|
|
||||||
let stderrTruncated = false;
|
|
||||||
// When discardOutput is set, skip stdout accumulation entirely and keep
|
|
||||||
// only a small stderr tail for diagnostics -- never fail on truncation.
|
|
||||||
// This prevents large `qmd update` runs from hitting the output cap.
|
|
||||||
const discard = opts?.discardOutput === true;
|
|
||||||
const timer = opts?.timeoutMs
|
|
||||||
? setTimeout(() => {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
reject(new Error(`qmd ${args.join(" ")} timed out after ${opts.timeoutMs}ms`));
|
|
||||||
}, opts.timeoutMs)
|
|
||||||
: null;
|
|
||||||
child.stdout.on("data", (data) => {
|
|
||||||
if (discard) {
|
|
||||||
return; // drain without accumulating
|
|
||||||
}
|
|
||||||
const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars);
|
|
||||||
stdout = next.text;
|
|
||||||
stdoutTruncated = stdoutTruncated || next.truncated;
|
|
||||||
});
|
|
||||||
child.stderr.on("data", (data) => {
|
|
||||||
const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars);
|
|
||||||
stderr = next.text;
|
|
||||||
stderrTruncated = stderrTruncated || next.truncated;
|
|
||||||
});
|
|
||||||
child.on("error", (err) => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
if (!discard && (stdoutTruncated || stderrTruncated)) {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`qmd ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (code === 0) {
|
|
||||||
resolve({ stdout, stderr });
|
|
||||||
} else {
|
|
||||||
reject(new Error(`qmd ${args.join(" ")} failed (code ${code}): ${stderr || stdout}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,62 +1250,17 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
shell?: boolean;
|
shell?: boolean;
|
||||||
windowsHide?: boolean;
|
windowsHide?: boolean;
|
||||||
}): Promise<{ stdout: string; stderr: string }> =>
|
}): Promise<{ stdout: string; stderr: string }> =>
|
||||||
await new Promise((resolve, reject) => {
|
await runCliCommand({
|
||||||
const commandSummary = `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`;
|
commandSummary: `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`,
|
||||||
const child = spawn(spawnInvocation.command, spawnInvocation.argv, {
|
spawnInvocation,
|
||||||
// Keep mcporter and direct qmd commands on the same agent-scoped XDG state.
|
// Keep mcporter and direct qmd commands on the same agent-scoped XDG state.
|
||||||
env: this.env,
|
env: this.env,
|
||||||
cwd: this.workspaceDir,
|
cwd: this.workspaceDir,
|
||||||
shell: spawnInvocation.shell,
|
timeoutMs: opts?.timeoutMs,
|
||||||
windowsHide: spawnInvocation.windowsHide,
|
maxOutputChars: this.maxQmdOutputChars,
|
||||||
});
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
let stdoutTruncated = false;
|
|
||||||
let stderrTruncated = false;
|
|
||||||
const timer = opts?.timeoutMs
|
|
||||||
? setTimeout(() => {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
reject(new Error(`mcporter ${args.join(" ")} timed out after ${opts.timeoutMs}ms`));
|
|
||||||
}, opts.timeoutMs)
|
|
||||||
: null;
|
|
||||||
child.stdout.on("data", (data) => {
|
|
||||||
const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars);
|
|
||||||
stdout = next.text;
|
|
||||||
stdoutTruncated = stdoutTruncated || next.truncated;
|
|
||||||
});
|
|
||||||
child.stderr.on("data", (data) => {
|
|
||||||
const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars);
|
|
||||||
stderr = next.text;
|
|
||||||
stderrTruncated = stderrTruncated || next.truncated;
|
|
||||||
});
|
|
||||||
child.on("error", (err) => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
if (stdoutTruncated || stderrTruncated) {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`mcporter ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (code === 0) {
|
|
||||||
resolve({ stdout, stderr });
|
|
||||||
} else {
|
|
||||||
reject(new Error(`${commandSummary} failed (code ${code}): ${stderr || stdout}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const primaryInvocation = resolveSpawnInvocation({
|
const primaryInvocation = resolveCliSpawnInvocation({
|
||||||
command: "mcporter",
|
command: "mcporter",
|
||||||
args,
|
args,
|
||||||
env: this.env,
|
env: this.env,
|
||||||
@@ -1411,7 +1269,13 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
try {
|
try {
|
||||||
return await runWithInvocation(primaryInvocation);
|
return await runWithInvocation(primaryInvocation);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isWindowsCmdSpawnEinval(err, primaryInvocation.command)) {
|
if (
|
||||||
|
!isWindowsCommandShimEinval({
|
||||||
|
err,
|
||||||
|
command: primaryInvocation.command,
|
||||||
|
commandBase: "mcporter",
|
||||||
|
})
|
||||||
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// Some Windows npm cmd shims can still throw EINVAL on spawn; retry through
|
// Some Windows npm cmd shims can still throw EINVAL on spawn; retry through
|
||||||
@@ -2232,15 +2096,3 @@ export class QmdMemoryManager implements MemorySearchManager {
|
|||||||
return [command, normalizedQuery, "--json", "-n", String(limit)];
|
return [command, normalizedQuery, "--json", "-n", String(limit)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendOutputWithCap(
|
|
||||||
current: string,
|
|
||||||
chunk: string,
|
|
||||||
maxChars: number,
|
|
||||||
): { text: string; truncated: boolean } {
|
|
||||||
const appended = current + chunk;
|
|
||||||
if (appended.length <= maxChars) {
|
|
||||||
return { text: appended, truncated: false };
|
|
||||||
}
|
|
||||||
return { text: appended.slice(-maxChars), truncated: true };
|
|
||||||
}
|
|
||||||
|
|||||||
144
src/memory/qmd-process.ts
Normal file
144
src/memory/qmd-process.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
materializeWindowsSpawnProgram,
|
||||||
|
resolveWindowsSpawnProgram,
|
||||||
|
} from "../plugin-sdk/windows-spawn.js";
|
||||||
|
|
||||||
|
export type CliSpawnInvocation = {
|
||||||
|
command: string;
|
||||||
|
argv: string[];
|
||||||
|
shell?: boolean;
|
||||||
|
windowsHide?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveWindowsCommandShim(command: string): string {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
const trimmed = command.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
const ext = path.extname(trimmed).toLowerCase();
|
||||||
|
if (ext === ".cmd" || ext === ".exe" || ext === ".bat") {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
const base = path.basename(trimmed).toLowerCase();
|
||||||
|
if (base === "qmd" || base === "mcporter") {
|
||||||
|
return `${trimmed}.cmd`;
|
||||||
|
}
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCliSpawnInvocation(params: {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
packageName: string;
|
||||||
|
}): CliSpawnInvocation {
|
||||||
|
const program = resolveWindowsSpawnProgram({
|
||||||
|
command: resolveWindowsCommandShim(params.command),
|
||||||
|
platform: process.platform,
|
||||||
|
env: params.env,
|
||||||
|
execPath: process.execPath,
|
||||||
|
packageName: params.packageName,
|
||||||
|
allowShellFallback: true,
|
||||||
|
});
|
||||||
|
return materializeWindowsSpawnProgram(program, params.args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWindowsCommandShimEinval(params: {
|
||||||
|
err: unknown;
|
||||||
|
command: string;
|
||||||
|
commandBase: string;
|
||||||
|
}): boolean {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const errno = params.err as NodeJS.ErrnoException | undefined;
|
||||||
|
if (errno?.code !== "EINVAL") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const escapedBase = params.commandBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
return new RegExp(`(^|[\\\\/])${escapedBase}\\.cmd$`, "i").test(params.command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCliCommand(params: {
|
||||||
|
commandSummary: string;
|
||||||
|
spawnInvocation: CliSpawnInvocation;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
cwd: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
maxOutputChars: number;
|
||||||
|
discardStdout?: boolean;
|
||||||
|
}): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(params.spawnInvocation.command, params.spawnInvocation.argv, {
|
||||||
|
env: params.env,
|
||||||
|
cwd: params.cwd,
|
||||||
|
shell: params.spawnInvocation.shell,
|
||||||
|
windowsHide: params.spawnInvocation.windowsHide,
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let stdoutTruncated = false;
|
||||||
|
let stderrTruncated = false;
|
||||||
|
const discardStdout = params.discardStdout === true;
|
||||||
|
const timer = params.timeoutMs
|
||||||
|
? setTimeout(() => {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
reject(new Error(`${params.commandSummary} timed out after ${params.timeoutMs}ms`));
|
||||||
|
}, params.timeoutMs)
|
||||||
|
: null;
|
||||||
|
child.stdout.on("data", (data) => {
|
||||||
|
if (discardStdout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = appendOutputWithCap(stdout, data.toString("utf8"), params.maxOutputChars);
|
||||||
|
stdout = next.text;
|
||||||
|
stdoutTruncated = stdoutTruncated || next.truncated;
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (data) => {
|
||||||
|
const next = appendOutputWithCap(stderr, data.toString("utf8"), params.maxOutputChars);
|
||||||
|
stderr = next.text;
|
||||||
|
stderrTruncated = stderrTruncated || next.truncated;
|
||||||
|
});
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (!discardStdout && (stdoutTruncated || stderrTruncated)) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`${params.commandSummary} produced too much output (limit ${params.maxOutputChars} chars)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
} else {
|
||||||
|
reject(new Error(`${params.commandSummary} failed (code ${code}): ${stderr || stdout}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOutputWithCap(
|
||||||
|
current: string,
|
||||||
|
chunk: string,
|
||||||
|
maxChars: number,
|
||||||
|
): { text: string; truncated: boolean } {
|
||||||
|
const appended = current + chunk;
|
||||||
|
if (appended.length <= maxChars) {
|
||||||
|
return { text: appended, truncated: false };
|
||||||
|
}
|
||||||
|
return { text: appended.slice(-maxChars), truncated: true };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user