Daemon: handle degraded systemd status checks (#39325)

* Daemon: handle degraded systemd status checks

* Changelog: note systemd status handling

* Update src/commands/status.service-summary.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-03-07 20:30:48 -05:00
committed by GitHub
parent c22a4450ee
commit 556a74d259
8 changed files with 211 additions and 55 deletions

View File

@@ -30,6 +30,7 @@ import { buildChannelsTable } from "./status-all/channels.js";
import { formatDurationPrecise, formatGatewayAuthUsed } from "./status-all/format.js";
import { pickGatewaySelfPresence } from "./status-all/gateway.js";
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
import { readServiceStatusSummary } from "./status.service-summary.js";
import { formatUpdateOneLiner } from "./status.update.js";
export async function statusAllCommand(
@@ -135,18 +136,14 @@ export async function statusAllCommand(
progress.setLabel("Checking services…");
const readServiceSummary = async (service: GatewayService) => {
try {
const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);
const installed = command != null;
const summary = await readServiceStatusSummary(service, service.label);
return {
label: service.label,
installed,
loaded,
loadedText: loaded ? service.loadedText : service.notLoadedText,
runtime: runtimeInfo,
label: summary.label,
installed: summary.installed,
managedByOpenClaw: summary.managedByOpenClaw,
loaded: summary.loaded,
loadedText: summary.loadedText,
runtime: summary.runtime,
};
} catch {
return null;
@@ -310,7 +307,7 @@ export async function statusAllCommand(
Item: "Gateway service",
Value: !daemon.installed
? `${daemon.label} not installed`
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
: `${daemon.label} ${daemon.managedByOpenClaw ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
}
: { Item: "Gateway service", Value: "unknown" },
nodeService
@@ -318,7 +315,7 @@ export async function statusAllCommand(
Item: "Node service",
Value: !nodeService.installed
? `${nodeService.label} not installed`
: `${nodeService.label} ${nodeService.installed ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`,
: `${nodeService.label} ${nodeService.managedByOpenClaw ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`,
}
: { Item: "Node service", Value: "unknown" },
{

View File

@@ -302,14 +302,14 @@ export async function statusCommand(
if (daemon.installed === false) {
return `${daemon.label} not installed`;
}
const installedPrefix = daemon.installed === true ? "installed · " : "";
const installedPrefix = daemon.managedByOpenClaw ? "installed · " : "";
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
})();
const nodeDaemonValue = (() => {
if (nodeDaemon.installed === false) {
return `${nodeDaemon.label} not installed`;
}
const installedPrefix = nodeDaemon.installed === true ? "installed · " : "";
const installedPrefix = nodeDaemon.managedByOpenClaw ? "installed · " : "";
return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.runtimeShort}` : ""}`;
})();

View File

@@ -1,43 +1,37 @@
import { resolveNodeService } from "../daemon/node-service.js";
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js";
import { formatDaemonRuntimeShort } from "./status.format.js";
import { readServiceStatusSummary } from "./status.service-summary.js";
type DaemonStatusSummary = {
label: string;
installed: boolean | null;
managedByOpenClaw: boolean;
externallyManaged: boolean;
loadedText: string;
runtimeShort: string | null;
};
async function buildDaemonStatusSummary(
service: GatewayService,
fallbackLabel: string,
serviceLabel: "gateway" | "node",
): Promise<DaemonStatusSummary> {
try {
const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);
const installed = command != null;
const loadedText = loaded ? service.loadedText : service.notLoadedText;
const runtimeShort = formatDaemonRuntimeShort(runtime);
return { label: service.label, installed, loadedText, runtimeShort };
} catch {
return {
label: fallbackLabel,
installed: null,
loadedText: "unknown",
runtimeShort: null,
};
}
const service = serviceLabel === "gateway" ? resolveGatewayService() : resolveNodeService();
const fallbackLabel = serviceLabel === "gateway" ? "Daemon" : "Node";
const summary = await readServiceStatusSummary(service, fallbackLabel);
return {
label: summary.label,
installed: summary.installed,
managedByOpenClaw: summary.managedByOpenClaw,
externallyManaged: summary.externallyManaged,
loadedText: summary.loadedText,
runtimeShort: formatDaemonRuntimeShort(summary.runtime),
};
}
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
return await buildDaemonStatusSummary("gateway");
}
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
return await buildDaemonStatusSummary("node");
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayService } from "../daemon/service.js";
import { readServiceStatusSummary } from "./status.service-summary.js";
function createService(overrides: Partial<GatewayService>): GatewayService {
return {
label: "systemd",
loadedText: "enabled",
notLoadedText: "disabled",
install: vi.fn(async () => {}),
uninstall: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
restart: vi.fn(async () => {}),
isLoaded: vi.fn(async () => false),
readCommand: vi.fn(async () => null),
readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
...overrides,
};
}
describe("readServiceStatusSummary", () => {
it("marks OpenClaw-managed services as installed", async () => {
const summary = await readServiceStatusSummary(
createService({
isLoaded: vi.fn(async () => true),
readCommand: vi.fn(async () => ({ programArguments: ["openclaw", "gateway", "run"] })),
readRuntime: vi.fn(async () => ({ status: "running" })),
}),
"Daemon",
);
expect(summary.installed).toBe(true);
expect(summary.managedByOpenClaw).toBe(true);
expect(summary.externallyManaged).toBe(false);
expect(summary.loadedText).toBe("enabled");
});
it("marks running unmanaged services as externally managed", async () => {
const summary = await readServiceStatusSummary(
createService({
readRuntime: vi.fn(async () => ({ status: "running" })),
}),
"Daemon",
);
expect(summary.installed).toBe(true);
expect(summary.managedByOpenClaw).toBe(false);
expect(summary.externallyManaged).toBe(true);
expect(summary.loadedText).toBe("running (externally managed)");
});
it("keeps missing services as not installed when nothing is running", async () => {
const summary = await readServiceStatusSummary(createService({}), "Daemon");
expect(summary.installed).toBe(false);
expect(summary.managedByOpenClaw).toBe(false);
expect(summary.externallyManaged).toBe(false);
expect(summary.loadedText).toBe("disabled");
});
});

View File

@@ -0,0 +1,52 @@
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import type { GatewayService } from "../daemon/service.js";
export type ServiceStatusSummary = {
label: string;
installed: boolean | null;
loaded: boolean;
managedByOpenClaw: boolean;
externallyManaged: boolean;
loadedText: string;
runtime: GatewayServiceRuntime | undefined;
};
export async function readServiceStatusSummary(
service: GatewayService,
fallbackLabel: string,
): Promise<ServiceStatusSummary> {
try {
const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);
const managedByOpenClaw = command != null;
const externallyManaged = !managedByOpenClaw && runtime?.status === "running";
const installed = managedByOpenClaw || externallyManaged;
const loadedText = externallyManaged
? "running (externally managed)"
: loaded
? service.loadedText
: service.notLoadedText;
return {
label: service.label,
installed,
loaded,
managedByOpenClaw,
externallyManaged,
loadedText,
runtime,
};
} catch {
return {
label: fallbackLabel,
installed: null,
loaded: false,
managedByOpenClaw: false,
externallyManaged: false,
loadedText: "unknown",
runtime: undefined,
};
}
}