refactor: centralize daemon service start state flow

This commit is contained in:
Peter Steinberger
2026-03-24 22:48:26 -07:00
parent 5dec3dddc4
commit 258a214bcb
7 changed files with 290 additions and 84 deletions

View File

@@ -205,9 +205,38 @@ describe("runServiceRestart token drift", () => {
opts: { json: true },
});
expect(service.isLoaded).toHaveBeenCalledTimes(1);
expect(service.isLoaded).toHaveBeenCalled();
const payload = readJsonLog<{ result?: string; message?: string }>();
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
it("fails start when restarting a stopped installed service errors", async () => {
service.isLoaded.mockResolvedValue(false);
service.restart.mockRejectedValue(new Error("launchctl kickstart failed: permission denied"));
await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1");
const payload = readJsonLog<{ ok?: boolean; error?: string }>();
expect(payload.ok).toBe(false);
expect(payload.error).toContain("launchctl kickstart failed: permission denied");
});
it("falls back to not-loaded hints when start finds no install artifacts", async () => {
service.isLoaded.mockResolvedValue(false);
service.readCommand.mockResolvedValue(null);
await runServiceStart({
serviceNoun: "Gateway",
service,
renderStartHints: () => ["openclaw gateway install"],
opts: { json: true },
});
const payload = readJsonLog<{ ok?: boolean; result?: string; hints?: string[] }>();
expect(payload.ok).toBe(true);
expect(payload.result).toBe("not-loaded");
expect(payload.hints).toEqual(["openclaw gateway install"]);
expect(service.restart).not.toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@ import { formatConfigIssueLines } from "../../config/issue-format.js";
import { resolveIsNixMode } from "../../config/paths.js";
import { checkTokenDrift } from "../../daemon/service-audit.js";
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
import { describeGatewayServiceRestart } from "../../daemon/service.js";
import { describeGatewayServiceRestart, startGatewayService } from "../../daemon/service.js";
import type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
@@ -77,6 +77,17 @@ function createActionIO(params: { action: DaemonAction; json: boolean }) {
return { stdout, emit, fail };
}
function emitActionMessage(params: {
json: boolean;
emit: ReturnType<typeof createActionIO>["emit"];
payload: Omit<DaemonActionResponse, "action">;
}) {
params.emit(params.payload);
if (!params.json && params.payload.message) {
defaultRuntime.log(params.payload.message);
}
}
async function handleServiceNotLoaded(params: {
serviceNoun: string;
service: GatewayService;
@@ -200,12 +211,13 @@ export async function runServiceStart(params: {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createActionIO({ action: "start", json });
const loaded = await resolveServiceLoadedOrFail({
serviceNoun: params.serviceNoun,
service: params.service,
fail,
});
if (loaded === null) {
if (
(await resolveServiceLoadedOrFail({
serviceNoun: params.serviceNoun,
service: params.service,
fail,
})) === null
) {
return;
}
// Pre-flight config validation (#35862) — run for both loaded and not-loaded
@@ -219,71 +231,45 @@ export async function runServiceStart(params: {
return;
}
}
if (!loaded) {
// Service was stopped (e.g. `gateway stop` booted out the LaunchAgent).
// Attempt a restart, which handles re-bootstrapping the service. Without
// this, `start` after `stop` just prints hints and does nothing (#53878).
try {
const restartResult = await params.service.restart({ env: process.env, stdout });
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
const postLoaded = await params.service.isLoaded({ env: process.env }).catch(() => true);
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, postLoaded),
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return;
} catch {
// Bootstrap failed (e.g. plist was deleted, not just booted out).
// Fall through to the not-loaded hints.
try {
const startResult = await startGatewayService(params.service, { env: process.env, stdout });
if (startResult.outcome === "missing-install") {
await handleServiceNotLoaded({
serviceNoun: params.serviceNoun,
service: params.service,
loaded,
loaded: startResult.state.loaded,
renderStartHints: params.renderStartHints,
json,
emit,
});
return;
}
}
try {
const restartResult = await params.service.restart({ env: process.env, stdout });
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
if (startResult.outcome === "scheduled") {
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, {
outcome: "scheduled",
});
emitActionMessage({
json,
emit,
payload: {
ok: true,
result: "scheduled",
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
},
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return;
}
emit({
ok: true,
result: "started",
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
});
} catch (err) {
const hints = params.renderStartHints();
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
return;
}
let started = true;
try {
started = await params.service.isLoaded({ env: process.env });
} catch {
started = true;
}
emit({
ok: true,
result: "started",
service: buildDaemonServiceSnapshot(params.service, started),
});
}
export async function runServiceStop(params: {
@@ -371,16 +357,17 @@ export async function runServiceRestart(params: {
restartStatus: ReturnType<typeof describeGatewayServiceRestart>,
serviceLoaded: boolean,
) => {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, serviceLoaded),
warnings: warnings.length ? warnings : undefined,
emitActionMessage({
json,
emit,
payload: {
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, serviceLoaded),
warnings: warnings.length ? warnings : undefined,
},
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return true;
};

View File

@@ -42,9 +42,11 @@ export function resetLifecycleServiceMocks() {
service.stage.mockClear();
service.isLoaded.mockClear();
service.readCommand.mockClear();
service.readRuntime.mockClear();
service.restart.mockClear();
service.isLoaded.mockResolvedValue(true);
service.readCommand.mockResolvedValue({ programArguments: [], environment: {} });
service.readRuntime.mockResolvedValue({ status: "running" });
service.restart.mockResolvedValue({ outcome: "completed" });
}

View File

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

View File

@@ -1,3 +1,5 @@
import type { GatewayServiceRuntime } from "./service-runtime.js";
export type GatewayServiceEnv = Record<string, string | undefined>;
export type GatewayServiceInstallArgs = {
@@ -35,6 +37,20 @@ export type GatewayServiceCommandConfig = {
sourcePath?: string;
};
export type GatewayServiceState = {
installed: boolean;
loaded: boolean;
running: boolean;
env: GatewayServiceEnv;
command: GatewayServiceCommandConfig | null;
runtime?: GatewayServiceRuntime;
};
export type GatewayServiceStartResult =
| { outcome: "started"; state: GatewayServiceState }
| { outcome: "scheduled"; state: GatewayServiceState }
| { outcome: "missing-install"; state: GatewayServiceState };
export type GatewayServiceRenderArgs = {
description?: string;
programArguments: string[];

View File

@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it } from "vitest";
import { describeGatewayServiceRestart, resolveGatewayService } from "./service.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayService } from "./service.js";
import {
describeGatewayServiceRestart,
readGatewayServiceState,
resolveGatewayService,
startGatewayService,
} from "./service.js";
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
@@ -21,6 +27,23 @@ afterEach(() => {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
});
function createService(overrides: Partial<GatewayService> = {}): GatewayService {
return {
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
stage: vi.fn(async () => {}),
install: vi.fn(async () => {}),
uninstall: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
restart: vi.fn(async () => ({ outcome: "completed" as const })),
isLoaded: vi.fn(async () => false),
readCommand: vi.fn(async () => null),
readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
...overrides,
};
}
describe("resolveGatewayService", () => {
it.each([
{ platform: "darwin" as const, label: "LaunchAgent", loadedText: "loaded" },
@@ -47,3 +70,93 @@ describe("resolveGatewayService", () => {
});
});
});
describe("readGatewayServiceState", () => {
it("tracks installed, loaded, and running separately", async () => {
const service = createService({
isLoaded: vi.fn(async () => true),
readCommand: vi.fn(async () => ({
programArguments: ["openclaw", "gateway", "run"],
environment: { OPENCLAW_GATEWAY_PORT: "18789" },
})),
readRuntime: vi.fn(async () => ({ status: "running" })),
});
const state = await readGatewayServiceState(service, {
env: { OPENCLAW_GATEWAY_PORT: "1" },
});
expect(state.installed).toBe(true);
expect(state.loaded).toBe(true);
expect(state.running).toBe(true);
expect(state.env.OPENCLAW_GATEWAY_PORT).toBe("18789");
});
});
describe("startGatewayService", () => {
it("returns missing-install without attempting restart", async () => {
const service = createService();
const result = await startGatewayService(service, {
env: {},
stdout: process.stdout,
});
expect(result.outcome).toBe("missing-install");
expect(service.restart).not.toHaveBeenCalled();
});
it("restarts stopped installed services and returns post-start state", async () => {
const readCommand = vi.fn(async () => ({
programArguments: ["openclaw", "gateway", "run"],
environment: { OPENCLAW_GATEWAY_PORT: "18789" },
}));
const isLoaded = vi
.fn<GatewayService["isLoaded"]>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const readRuntime = vi
.fn<GatewayService["readRuntime"]>()
.mockResolvedValueOnce({ status: "stopped" })
.mockResolvedValueOnce({ status: "running" });
const service = createService({
readCommand,
isLoaded,
readRuntime,
});
const result = await startGatewayService(service, {
env: {},
stdout: process.stdout,
});
expect(result.outcome).toBe("started");
expect(service.restart).toHaveBeenCalledTimes(1);
expect(result.state.installed).toBe(true);
expect(result.state.loaded).toBe(true);
expect(result.state.running).toBe(true);
});
it("falls back to missing-install when restart fails and install artifacts are gone", async () => {
const readCommand = vi
.fn<GatewayService["readCommand"]>()
.mockResolvedValueOnce({
programArguments: ["openclaw", "gateway", "run"],
})
.mockResolvedValueOnce(null);
const service = createService({
readCommand,
restart: vi.fn(async () => {
throw new Error("launchctl bootstrap failed");
}),
});
const result = await startGatewayService(service, {
env: {},
stdout: process.stdout,
});
expect(result.outcome).toBe("missing-install");
expect(result.state.installed).toBe(false);
});
});

View File

@@ -27,7 +27,9 @@ import type {
GatewayServiceInstallArgs,
GatewayServiceManageArgs,
GatewayServiceRestartResult,
GatewayServiceStartResult,
GatewayServiceStageArgs,
GatewayServiceState,
} from "./service-types.js";
import {
installSystemdService,
@@ -47,7 +49,9 @@ export type {
GatewayServiceInstallArgs,
GatewayServiceManageArgs,
GatewayServiceRestartResult,
GatewayServiceStartResult,
GatewayServiceStageArgs,
GatewayServiceState,
} from "./service-types.js";
function ignoreServiceWriteResult<TArgs extends GatewayServiceInstallArgs>(
@@ -72,6 +76,71 @@ export type GatewayService = {
readRuntime: (env: GatewayServiceEnv) => Promise<GatewayServiceRuntime>;
};
function mergeGatewayServiceEnv(
baseEnv: GatewayServiceEnv,
command: GatewayServiceCommandConfig | null,
): GatewayServiceEnv {
if (!command?.environment) {
return baseEnv;
}
return {
...baseEnv,
...command.environment,
};
}
export async function readGatewayServiceState(
service: GatewayService,
args: GatewayServiceEnvArgs = {},
): Promise<GatewayServiceState> {
const baseEnv = args.env ?? (process.env as GatewayServiceEnv);
const command = await service.readCommand(baseEnv).catch(() => null);
const env = mergeGatewayServiceEnv(baseEnv, command);
const [loaded, runtime] = await Promise.all([
service.isLoaded({ env }).catch(() => false),
service.readRuntime(env).catch(() => undefined),
]);
return {
installed: command !== null,
loaded,
running: runtime?.status === "running",
env,
command,
runtime,
};
}
export async function startGatewayService(
service: GatewayService,
args: GatewayServiceControlArgs,
): Promise<GatewayServiceStartResult> {
const state = await readGatewayServiceState(service, { env: args.env });
if (!state.loaded && !state.installed) {
return {
outcome: "missing-install",
state,
};
}
try {
const restartResult = await service.restart({ ...args, env: state.env });
const nextState = await readGatewayServiceState(service, { env: state.env });
return {
outcome: restartResult.outcome === "scheduled" ? "scheduled" : "started",
state: nextState,
};
} catch (err) {
const nextState = await readGatewayServiceState(service, { env: state.env });
if (!nextState.installed) {
return {
outcome: "missing-install",
state: nextState,
};
}
throw err;
}
}
export function describeGatewayServiceRestart(
serviceNoun: string,
result: GatewayServiceRestartResult,