mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 19:01:47 +03:00
fix: improve gateway diagnostics
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseLaunchctlPrint } from "./launchd.js";
|
||||
|
||||
describe("launchd runtime parsing", () => {
|
||||
it("parses state, pid, and exit status", () => {
|
||||
const output = [
|
||||
"state = running",
|
||||
"pid = 4242",
|
||||
"last exit status = 1",
|
||||
"last exit reason = exited",
|
||||
].join("\n");
|
||||
expect(parseLaunchctlPrint(output)).toEqual({
|
||||
state: "running",
|
||||
pid: 4242,
|
||||
lastExitStatus: 1,
|
||||
lastExitReason: "exited",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GATEWAY_LAUNCH_AGENT_LABEL,
|
||||
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
|
||||
} from "./constants.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
function resolveHomeDir(env: Record<string, string | undefined>): string {
|
||||
@@ -196,6 +197,38 @@ function resolveGuiDomain(): string {
|
||||
return `gui/${process.getuid()}`;
|
||||
}
|
||||
|
||||
export type LaunchctlPrintInfo = {
|
||||
state?: string;
|
||||
pid?: number;
|
||||
lastExitStatus?: number;
|
||||
lastExitReason?: string;
|
||||
};
|
||||
|
||||
export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
|
||||
const info: LaunchctlPrintInfo = {};
|
||||
for (const rawLine of output.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const match = line.match(/^([a-zA-Z\s]+?)\s*=\s*(.+)$/);
|
||||
if (!match) continue;
|
||||
const key = match[1]?.trim().toLowerCase();
|
||||
const value = match[2]?.trim();
|
||||
if (!key || value === undefined) continue;
|
||||
if (key === "state") {
|
||||
info.state = value;
|
||||
} else if (key === "pid") {
|
||||
const pid = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(pid)) info.pid = pid;
|
||||
} else if (key === "last exit status") {
|
||||
const status = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(status)) info.lastExitStatus = status;
|
||||
} else if (key === "last exit reason") {
|
||||
info.lastExitReason = value;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
export async function isLaunchAgentLoaded(): Promise<boolean> {
|
||||
const domain = resolveGuiDomain();
|
||||
const label = GATEWAY_LAUNCH_AGENT_LABEL;
|
||||
@@ -203,6 +236,50 @@ export async function isLaunchAgentLoaded(): Promise<boolean> {
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
async function hasLaunchAgentPlist(
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<boolean> {
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
try {
|
||||
await fs.access(plistPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLaunchAgentRuntime(
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<GatewayServiceRuntime> {
|
||||
const domain = resolveGuiDomain();
|
||||
const label = GATEWAY_LAUNCH_AGENT_LABEL;
|
||||
const res = await execLaunchctl(["print", `${domain}/${label}`]);
|
||||
if (res.code !== 0) {
|
||||
return {
|
||||
status: "unknown",
|
||||
detail: (res.stderr || res.stdout).trim() || undefined,
|
||||
missingUnit: true,
|
||||
};
|
||||
}
|
||||
const parsed = parseLaunchctlPrint(res.stdout || res.stderr || "");
|
||||
const plistExists = await hasLaunchAgentPlist(env);
|
||||
const state = parsed.state?.toLowerCase();
|
||||
const status =
|
||||
state === "running" || parsed.pid
|
||||
? "running"
|
||||
: state
|
||||
? "stopped"
|
||||
: "unknown";
|
||||
return {
|
||||
status,
|
||||
state: parsed.state,
|
||||
pid: parsed.pid,
|
||||
lastExitStatus: parsed.lastExitStatus,
|
||||
lastExitReason: parsed.lastExitReason,
|
||||
cachedLabel: !plistExists,
|
||||
};
|
||||
}
|
||||
|
||||
export type LegacyLaunchAgent = {
|
||||
label: string;
|
||||
plistPath: string;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSchtasksQuery } from "./schtasks.js";
|
||||
|
||||
describe("schtasks runtime parsing", () => {
|
||||
it("parses status and last run info", () => {
|
||||
const output = [
|
||||
"TaskName: \\Clawdbot Gateway",
|
||||
"Status: Ready",
|
||||
"Last Run Time: 1/8/2026 1:23:45 AM",
|
||||
"Last Run Result: 0x0",
|
||||
].join("\r\n");
|
||||
expect(parseSchtasksQuery(output)).toEqual({
|
||||
status: "Ready",
|
||||
lastRunTime: "1/8/2026 1:23:45 AM",
|
||||
lastRunResult: "0x0",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GATEWAY_WINDOWS_TASK_NAME,
|
||||
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
|
||||
} from "./constants.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -102,6 +103,33 @@ export async function readScheduledTaskCommand(
|
||||
}
|
||||
}
|
||||
|
||||
export type ScheduledTaskInfo = {
|
||||
status?: string;
|
||||
lastRunTime?: string;
|
||||
lastRunResult?: string;
|
||||
};
|
||||
|
||||
export function parseSchtasksQuery(output: string): ScheduledTaskInfo {
|
||||
const info: ScheduledTaskInfo = {};
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const idx = line.indexOf(":");
|
||||
if (idx <= 0) continue;
|
||||
const key = line.slice(0, idx).trim().toLowerCase();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (!value) continue;
|
||||
if (key === "status") {
|
||||
info.status = value;
|
||||
} else if (key === "last run time") {
|
||||
info.lastRunTime = value;
|
||||
} else if (key === "last run result") {
|
||||
info.lastRunResult = value;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function buildTaskScript({
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
@@ -274,6 +302,44 @@ export async function isScheduledTaskInstalled(): Promise<boolean> {
|
||||
const res = await execSchtasks(["/Query", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
export async function readScheduledTaskRuntime(): Promise<GatewayServiceRuntime> {
|
||||
try {
|
||||
await assertSchtasksAvailable();
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unknown",
|
||||
detail: String(err),
|
||||
};
|
||||
}
|
||||
const res = await execSchtasks([
|
||||
"/Query",
|
||||
"/TN",
|
||||
GATEWAY_WINDOWS_TASK_NAME,
|
||||
"/V",
|
||||
"/FO",
|
||||
"LIST",
|
||||
]);
|
||||
if (res.code !== 0) {
|
||||
const detail = (res.stderr || res.stdout).trim();
|
||||
const missing = detail.toLowerCase().includes("cannot find the file");
|
||||
return {
|
||||
status: missing ? "stopped" : "unknown",
|
||||
detail: detail || undefined,
|
||||
missingUnit: missing,
|
||||
};
|
||||
}
|
||||
const parsed = parseSchtasksQuery(res.stdout || "");
|
||||
const statusRaw = parsed.status?.toLowerCase();
|
||||
const status =
|
||||
statusRaw === "running" ? "running" : statusRaw ? "stopped" : "unknown";
|
||||
return {
|
||||
status,
|
||||
state: parsed.status,
|
||||
lastRunTime: parsed.lastRunTime,
|
||||
lastRunResult: parsed.lastRunResult,
|
||||
};
|
||||
}
|
||||
export type LegacyScheduledTask = {
|
||||
name: string;
|
||||
scriptPath: string;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export type GatewayServiceRuntime = {
|
||||
status?: "running" | "stopped" | "unknown";
|
||||
state?: string;
|
||||
subState?: string;
|
||||
pid?: number;
|
||||
lastExitStatus?: number;
|
||||
lastExitReason?: string;
|
||||
lastRunResult?: string;
|
||||
lastRunTime?: string;
|
||||
detail?: string;
|
||||
cachedLabel?: boolean;
|
||||
missingUnit?: boolean;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
installLaunchAgent,
|
||||
isLaunchAgentLoaded,
|
||||
readLaunchAgentProgramArguments,
|
||||
readLaunchAgentRuntime,
|
||||
restartLaunchAgent,
|
||||
stopLaunchAgent,
|
||||
uninstallLaunchAgent,
|
||||
@@ -10,14 +11,17 @@ import {
|
||||
installScheduledTask,
|
||||
isScheduledTaskInstalled,
|
||||
readScheduledTaskCommand,
|
||||
readScheduledTaskRuntime,
|
||||
restartScheduledTask,
|
||||
stopScheduledTask,
|
||||
uninstallScheduledTask,
|
||||
} from "./schtasks.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
import {
|
||||
installSystemdService,
|
||||
isSystemdServiceEnabled,
|
||||
readSystemdServiceExecStart,
|
||||
readSystemdServiceRuntime,
|
||||
restartSystemdService,
|
||||
stopSystemdService,
|
||||
uninstallSystemdService,
|
||||
@@ -49,6 +53,9 @@ export type GatewayService = {
|
||||
programArguments: string[];
|
||||
workingDirectory?: string;
|
||||
} | null>;
|
||||
readRuntime: (
|
||||
env: Record<string, string | undefined>,
|
||||
) => Promise<GatewayServiceRuntime>;
|
||||
};
|
||||
|
||||
export function resolveGatewayService(): GatewayService {
|
||||
@@ -71,6 +78,7 @@ export function resolveGatewayService(): GatewayService {
|
||||
},
|
||||
isLoaded: async () => isLaunchAgentLoaded(),
|
||||
readCommand: readLaunchAgentProgramArguments,
|
||||
readRuntime: readLaunchAgentRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +101,7 @@ export function resolveGatewayService(): GatewayService {
|
||||
},
|
||||
isLoaded: async () => isSystemdServiceEnabled(),
|
||||
readCommand: readSystemdServiceExecStart,
|
||||
readRuntime: async () => await readSystemdServiceRuntime(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +124,7 @@ export function resolveGatewayService(): GatewayService {
|
||||
},
|
||||
isLoaded: async () => isScheduledTaskInstalled(),
|
||||
readCommand: readScheduledTaskCommand,
|
||||
readRuntime: async () => await readScheduledTaskRuntime(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+16
-38
@@ -1,43 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runExec } from "../process/exec.js";
|
||||
import { readSystemdUserLingerStatus } from "./systemd.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runExec: vi.fn(),
|
||||
runCommandWithTimeout: vi.fn(),
|
||||
}));
|
||||
import { parseSystemdShow } from "./systemd.js";
|
||||
|
||||
const runExecMock = vi.mocked(runExec);
|
||||
|
||||
describe("readSystemdUserLingerStatus", () => {
|
||||
beforeEach(() => {
|
||||
runExecMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns yes when loginctl reports Linger=yes", async () => {
|
||||
runExecMock.mockResolvedValue({
|
||||
stdout: "Linger=yes\n",
|
||||
stderr: "",
|
||||
describe("systemd runtime parsing", () => {
|
||||
it("parses active state details", () => {
|
||||
const output = [
|
||||
"ActiveState=inactive",
|
||||
"SubState=dead",
|
||||
"MainPID=0",
|
||||
"ExecMainStatus=2",
|
||||
"ExecMainCode=exited",
|
||||
].join("\n");
|
||||
expect(parseSystemdShow(output)).toEqual({
|
||||
activeState: "inactive",
|
||||
subState: "dead",
|
||||
execMainStatus: 2,
|
||||
execMainCode: "exited",
|
||||
});
|
||||
const result = await readSystemdUserLingerStatus({ USER: "tobi" });
|
||||
expect(result).toEqual({ user: "tobi", linger: "yes" });
|
||||
});
|
||||
|
||||
it("returns no when loginctl reports Linger=no", async () => {
|
||||
runExecMock.mockResolvedValue({
|
||||
stdout: "Linger=no\n",
|
||||
stderr: "",
|
||||
});
|
||||
const result = await readSystemdUserLingerStatus({ USER: "tobi" });
|
||||
expect(result).toEqual({ user: "tobi", linger: "no" });
|
||||
});
|
||||
|
||||
it("returns null when Linger is missing", async () => {
|
||||
runExecMock.mockResolvedValue({
|
||||
stdout: "UID=1000\n",
|
||||
stderr: "",
|
||||
});
|
||||
const result = await readSystemdUserLingerStatus({ USER: "tobi" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GATEWAY_SYSTEMD_SERVICE_NAME,
|
||||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
||||
} from "./constants.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -215,6 +216,39 @@ export async function readSystemdServiceExecStart(
|
||||
}
|
||||
}
|
||||
|
||||
export type SystemdServiceInfo = {
|
||||
activeState?: string;
|
||||
subState?: string;
|
||||
mainPid?: number;
|
||||
execMainStatus?: number;
|
||||
execMainCode?: string;
|
||||
};
|
||||
|
||||
export function parseSystemdShow(output: string): SystemdServiceInfo {
|
||||
const info: SystemdServiceInfo = {};
|
||||
for (const rawLine of output.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || !line.includes("=")) continue;
|
||||
const [key, ...rest] = line.split("=");
|
||||
const value = rest.join("=").trim();
|
||||
if (!key) continue;
|
||||
if (key === "ActiveState") {
|
||||
info.activeState = value;
|
||||
} else if (key === "SubState") {
|
||||
info.subState = value;
|
||||
} else if (key === "MainPID") {
|
||||
const pid = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(pid) && pid > 0) info.mainPid = pid;
|
||||
} else if (key === "ExecMainStatus") {
|
||||
const status = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(status)) info.execMainStatus = status;
|
||||
} else if (key === "ExecMainCode") {
|
||||
info.execMainCode = value;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
async function execSystemctl(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
@@ -369,6 +403,47 @@ export async function isSystemdServiceEnabled(): Promise<boolean> {
|
||||
const res = await execSystemctl(["--user", "is-enabled", unitName]);
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
export async function readSystemdServiceRuntime(): Promise<GatewayServiceRuntime> {
|
||||
try {
|
||||
await assertSystemdAvailable();
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unknown",
|
||||
detail: String(err),
|
||||
};
|
||||
}
|
||||
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
|
||||
const res = await execSystemctl([
|
||||
"--user",
|
||||
"show",
|
||||
unitName,
|
||||
"--no-page",
|
||||
"--property",
|
||||
"ActiveState,SubState,MainPID,ExecMainStatus,ExecMainCode",
|
||||
]);
|
||||
if (res.code !== 0) {
|
||||
const detail = (res.stderr || res.stdout).trim();
|
||||
const missing = detail.toLowerCase().includes("not found");
|
||||
return {
|
||||
status: missing ? "stopped" : "unknown",
|
||||
detail: detail || undefined,
|
||||
missingUnit: missing,
|
||||
};
|
||||
}
|
||||
const parsed = parseSystemdShow(res.stdout || "");
|
||||
const activeState = parsed.activeState?.toLowerCase();
|
||||
const status =
|
||||
activeState === "active" ? "running" : activeState ? "stopped" : "unknown";
|
||||
return {
|
||||
status,
|
||||
state: parsed.activeState,
|
||||
subState: parsed.subState,
|
||||
pid: parsed.mainPid,
|
||||
lastExitStatus: parsed.execMainStatus,
|
||||
lastExitReason: parsed.execMainCode,
|
||||
};
|
||||
}
|
||||
export type LegacySystemdUnit = {
|
||||
name: string;
|
||||
unitPath: string;
|
||||
|
||||
Reference in New Issue
Block a user