fix(cli): handle scheduled gateway restarts consistently

This commit is contained in:
Peter Steinberger
2026-03-12 01:38:39 +00:00
parent 841ee24340
commit b31836317a
13 changed files with 456 additions and 41 deletions

View File

@@ -40,11 +40,12 @@ vi.mock("../../runtime.js", () => ({
}));
let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart;
let runServiceStart: typeof import("./lifecycle-core.js").runServiceStart;
let runServiceStop: typeof import("./lifecycle-core.js").runServiceStop;
describe("runServiceRestart token drift", () => {
beforeAll(async () => {
({ runServiceRestart, runServiceStop } = await import("./lifecycle-core.js"));
({ runServiceRestart, runServiceStart, runServiceStop } = await import("./lifecycle-core.js"));
});
beforeEach(() => {
@@ -196,4 +197,21 @@ describe("runServiceRestart token drift", () => {
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
it("emits scheduled when service start routes through a scheduled restart", async () => {
service.restart.mockResolvedValue({ outcome: "scheduled" });
await runServiceStart({
serviceNoun: "Gateway",
service,
renderStartHints: () => [],
opts: { json: true },
});
expect(service.isLoaded).toHaveBeenCalledTimes(1);
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
expect(payload.result).toBe("scheduled");
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
});

View File

@@ -4,6 +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 type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
@@ -224,7 +225,20 @@ export async function runServiceStart(params: {
}
try {
await params.service.restart({ env: process.env, stdout });
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 (!json) {
defaultRuntime.log(restartStatus.message);
}
return;
}
} catch (err) {
const hints = params.renderStartHints();
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
@@ -318,7 +332,7 @@ export async function runServiceRestart(params: {
renderStartHints: () => string[];
opts?: DaemonLifecycleOptions;
checkTokenDrift?: boolean;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<GatewayServiceRestartResult | void>;
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
}): Promise<boolean> {
const json = Boolean(params.opts?.json);
@@ -407,22 +421,38 @@ export async function runServiceRestart(params: {
if (loaded) {
restartResult = await params.service.restart({ env: process.env, stdout });
}
if (restartResult.outcome === "scheduled") {
const message = `restart scheduled, ${params.serviceNoun.toLowerCase()} will restart momentarily`;
let restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: "scheduled",
message,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(message);
defaultRuntime.log(restartStatus.message);
}
return true;
}
if (params.postRestartCheck) {
await params.postRestartCheck({ json, stdout, warnings, fail });
const postRestartResult = await params.postRestartCheck({ json, stdout, warnings, fail });
if (postRestartResult) {
restartStatus = describeGatewayServiceRestart(params.serviceNoun, postRestartResult);
if (restartStatus.scheduled) {
emit({
ok: true,
result: restartStatus.daemonActionResult,
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(restartStatus.message);
}
return true;
}
}
}
let restarted = loaded;
if (loaded) {

View File

@@ -132,6 +132,7 @@ describe("runDaemonRestart health checks", () => {
programArguments: ["openclaw", "gateway", "--port", "18789"],
environment: {},
});
service.restart.mockResolvedValue({ outcome: "completed" });
runServiceRestart.mockImplementation(async (params: RestartParams) => {
const fail = (message: string, hints?: string[]) => {
@@ -204,6 +205,25 @@ describe("runDaemonRestart health checks", () => {
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(2);
});
it("skips stale-pid retry health checks when the retry restart is only scheduled", async () => {
const unhealthy: RestartHealthSnapshot = {
healthy: false,
staleGatewayPids: [1993],
runtime: { status: "stopped" },
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
};
waitForGatewayHealthyRestart.mockResolvedValueOnce(unhealthy);
terminateStaleGatewayPids.mockResolvedValue([1993]);
service.restart.mockResolvedValueOnce({ outcome: "scheduled" });
const result = await runDaemonRestart({ json: true });
expect(result).toBe(true);
expect(terminateStaleGatewayPids).toHaveBeenCalledWith([1993]);
expect(service.restart).toHaveBeenCalledTimes(1);
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1);
});
it("fails restart when gateway remains unhealthy", async () => {
const unhealthy: RestartHealthSnapshot = {
healthy: false,

View File

@@ -286,7 +286,10 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
await terminateStaleGatewayPids(health.staleGatewayPids);
await service.restart({ env: process.env, stdout });
const retryRestart = await service.restart({ env: process.env, stdout });
if (retryRestart.outcome === "scheduled") {
return retryRestart;
}
health = await waitForGatewayHealthyRestart({
service,
port: restartPort,

View File

@@ -1,13 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const withProgress = vi.hoisted(() => vi.fn(async (_opts, run) => run({ setLabel: vi.fn() })));
const progressSetLabel = vi.hoisted(() => vi.fn());
const withProgress = vi.hoisted(() =>
vi.fn(async (_opts, run) => run({ setLabel: progressSetLabel })),
);
const loadConfig = vi.hoisted(() => vi.fn());
const resolveGatewayInstallToken = vi.hoisted(() => vi.fn());
const buildGatewayInstallPlan = vi.hoisted(() => vi.fn());
const note = vi.hoisted(() => vi.fn());
const serviceIsLoaded = vi.hoisted(() => vi.fn(async () => false));
const serviceInstall = vi.hoisted(() => vi.fn(async () => {}));
const serviceRestart = vi.hoisted(() =>
vi.fn<() => Promise<{ outcome: "completed" } | { outcome: "scheduled" }>>(async () => ({
outcome: "completed",
})),
);
const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {}));
const select = vi.hoisted(() => vi.fn(async () => "node"));
vi.mock("../cli/progress.js", () => ({
withProgress,
@@ -32,7 +41,7 @@ vi.mock("../terminal/note.js", () => ({
vi.mock("./configure.shared.js", () => ({
confirm: vi.fn(async () => true),
select: vi.fn(async () => "node"),
select,
}));
vi.mock("./daemon-runtime.js", () => ({
@@ -40,12 +49,17 @@ vi.mock("./daemon-runtime.js", () => ({
GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }],
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: vi.fn(() => ({
isLoaded: serviceIsLoaded,
install: serviceInstall,
})),
}));
vi.mock("../daemon/service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../daemon/service.js")>();
return {
...actual,
resolveGatewayService: vi.fn(() => ({
isLoaded: serviceIsLoaded,
install: serviceInstall,
restart: serviceRestart,
})),
};
});
vi.mock("./onboard-helpers.js", () => ({
guardCancel: (value: unknown) => value,
@@ -60,8 +74,10 @@ const { maybeInstallDaemon } = await import("./configure.daemon.js");
describe("maybeInstallDaemon", () => {
beforeEach(() => {
vi.clearAllMocks();
progressSetLabel.mockReset();
serviceIsLoaded.mockResolvedValue(false);
serviceInstall.mockResolvedValue(undefined);
serviceRestart.mockResolvedValue({ outcome: "completed" });
loadConfig.mockReturnValue({});
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
@@ -152,4 +168,19 @@ describe("maybeInstallDaemon", () => {
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("shows restart scheduled when a loaded service defers restart handoff", async () => {
serviceIsLoaded.mockResolvedValue(true);
select.mockResolvedValueOnce("restart");
serviceRestart.mockResolvedValueOnce({ outcome: "scheduled" });
await maybeInstallDaemon({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
port: 18789,
});
expect(serviceRestart).toHaveBeenCalledTimes(1);
expect(serviceInstall).not.toHaveBeenCalled();
expect(progressSetLabel).toHaveBeenLastCalledWith("Gateway service restart scheduled.");
});
});

View File

@@ -1,6 +1,6 @@
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js";
import { isNonFatalSystemdInstallProbeError } from "../daemon/systemd.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
@@ -50,11 +50,13 @@ export async function maybeInstallDaemon(params: {
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
async (progress) => {
progress.setLabel("Restarting Gateway service…");
await service.restart({
const restartResult = await service.restart({
env: process.env,
stdout: process.stdout,
});
progress.setLabel("Gateway service restarted.");
progress.setLabel(
describeGatewayServiceRestart("Gateway", restartResult).progressMessage,
);
},
);
shouldCheckLinger = true;

View File

@@ -0,0 +1,194 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const service = vi.hoisted(() => ({
isLoaded: vi.fn(),
readRuntime: vi.fn(),
restart: vi.fn(),
install: vi.fn(),
readCommand: vi.fn(),
}));
const note = vi.hoisted(() => vi.fn());
const sleep = vi.hoisted(() => vi.fn(async () => {}));
const healthCommand = vi.hoisted(() => vi.fn(async () => {}));
const inspectPortUsage = vi.hoisted(() => vi.fn());
const readLastGatewayErrorLine = vi.hoisted(() => vi.fn(async () => null));
vi.mock("../config/config.js", () => ({
resolveGatewayPort: vi.fn(() => 18789),
}));
vi.mock("../daemon/constants.js", () => ({
resolveGatewayLaunchAgentLabel: vi.fn(() => "ai.openclaw.gateway"),
resolveNodeLaunchAgentLabel: vi.fn(() => "ai.openclaw.node"),
}));
vi.mock("../daemon/diagnostics.js", () => ({
readLastGatewayErrorLine,
}));
vi.mock("../daemon/launchd.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../daemon/launchd.js")>();
return {
...actual,
isLaunchAgentListed: vi.fn(async () => false),
isLaunchAgentLoaded: vi.fn(async () => false),
launchAgentPlistExists: vi.fn(async () => false),
repairLaunchAgentBootstrap: vi.fn(async () => ({ ok: true })),
};
});
vi.mock("../daemon/service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../daemon/service.js")>();
return {
...actual,
resolveGatewayService: () => service,
};
});
vi.mock("../daemon/systemd-hints.js", () => ({
renderSystemdUnavailableHints: vi.fn(() => []),
}));
vi.mock("../daemon/systemd.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../daemon/systemd.js")>();
return {
...actual,
isSystemdUserServiceAvailable: vi.fn(async () => true),
};
});
vi.mock("../infra/ports.js", () => ({
inspectPortUsage,
formatPortDiagnostics: vi.fn(() => []),
}));
vi.mock("../infra/wsl.js", () => ({
isWSL: vi.fn(async () => false),
}));
vi.mock("../terminal/note.js", () => ({
note,
}));
vi.mock("../utils.js", () => ({
sleep,
}));
vi.mock("./daemon-install-helpers.js", () => ({
buildGatewayInstallPlan: vi.fn(),
gatewayInstallErrorHint: vi.fn(() => "hint"),
}));
vi.mock("./doctor-format.js", () => ({
buildGatewayRuntimeHints: vi.fn(() => []),
formatGatewayRuntimeSummary: vi.fn(() => null),
}));
vi.mock("./gateway-install-token.js", () => ({
resolveGatewayInstallToken: vi.fn(),
}));
vi.mock("./health-format.js", () => ({
formatHealthCheckFailure: vi.fn(() => "health failed"),
}));
vi.mock("./health.js", () => ({
healthCommand,
}));
describe("maybeRepairGatewayDaemon", () => {
let maybeRepairGatewayDaemon: typeof import("./doctor-gateway-daemon-flow.js").maybeRepairGatewayDaemon;
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
beforeAll(async () => {
({ maybeRepairGatewayDaemon } = await import("./doctor-gateway-daemon-flow.js"));
});
beforeEach(() => {
vi.clearAllMocks();
service.isLoaded.mockResolvedValue(true);
service.readRuntime.mockResolvedValue({ status: "running" });
service.restart.mockResolvedValue({ outcome: "completed" });
inspectPortUsage.mockResolvedValue({
port: 18789,
status: "free",
listeners: [],
hints: [],
});
});
afterEach(() => {
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
});
function setPlatform(platform: NodeJS.Platform) {
if (!originalPlatformDescriptor) {
return;
}
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: platform,
});
}
function createPrompter(confirmImpl: (message: string) => boolean) {
return {
confirm: vi.fn(),
confirmRepair: vi.fn(),
confirmAggressive: vi.fn(),
confirmSkipInNonInteractive: vi.fn(async ({ message }: { message: string }) =>
confirmImpl(message),
),
select: vi.fn(),
shouldRepair: false,
shouldForce: false,
};
}
it("skips restart verification when a running service restart is only scheduled", async () => {
setPlatform("linux");
service.restart.mockResolvedValueOnce({ outcome: "scheduled" });
await maybeRepairGatewayDaemon({
cfg: { gateway: {} },
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
prompter: createPrompter((message) => message === "Restart gateway service now?"),
options: { deep: false },
gatewayDetailsMessage: "details",
healthOk: false,
});
expect(service.restart).toHaveBeenCalledTimes(1);
expect(note).toHaveBeenCalledWith(
"restart scheduled, gateway will restart momentarily",
"Gateway",
);
expect(sleep).not.toHaveBeenCalled();
expect(healthCommand).not.toHaveBeenCalled();
});
it("skips start verification when a stopped service start is only scheduled", async () => {
setPlatform("linux");
service.readRuntime.mockResolvedValue({ status: "stopped" });
service.restart.mockResolvedValueOnce({ outcome: "scheduled" });
await maybeRepairGatewayDaemon({
cfg: { gateway: {} },
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
prompter: createPrompter((message) => message === "Start gateway service now?"),
options: { deep: false },
gatewayDetailsMessage: "details",
healthOk: false,
});
expect(service.restart).toHaveBeenCalledTimes(1);
expect(note).toHaveBeenCalledWith(
"restart scheduled, gateway will restart momentarily",
"Gateway",
);
expect(sleep).not.toHaveBeenCalled();
expect(healthCommand).not.toHaveBeenCalled();
});
});

View File

@@ -12,7 +12,7 @@ import {
launchAgentPlistExists,
repairLaunchAgentBootstrap,
} from "../daemon/launchd.js";
import { resolveGatewayService } from "../daemon/service.js";
import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js";
import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
@@ -235,11 +235,16 @@ export async function maybeRepairGatewayDaemon(params: {
initialValue: true,
});
if (start) {
await service.restart({
const restartResult = await service.restart({
env: process.env,
stdout: process.stdout,
});
await sleep(1500);
const restartStatus = describeGatewayServiceRestart("Gateway", restartResult);
if (!restartStatus.scheduled) {
await sleep(1500);
} else {
note(restartStatus.message, "Gateway");
}
}
}
@@ -257,10 +262,15 @@ export async function maybeRepairGatewayDaemon(params: {
initialValue: true,
});
if (restart) {
await service.restart({
const restartResult = await service.restart({
env: process.env,
stdout: process.stdout,
});
const restartStatus = describeGatewayServiceRestart("Gateway", restartResult);
if (restartStatus.scheduled) {
note(restartStatus.message, "Gateway");
return;
}
await sleep(1500);
try {
await healthCommand({ json: false, timeoutMs: 10_000 }, params.runtime);

View File

@@ -10,7 +10,7 @@ function createService(overrides: Partial<GatewayService>): GatewayService {
install: vi.fn(async () => {}),
uninstall: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
restart: 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 })),

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { resolveGatewayService } from "./service.js";
import { describeGatewayServiceRestart, resolveGatewayService } from "./service.js";
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
@@ -37,4 +37,13 @@ describe("resolveGatewayService", () => {
setPlatform("aix");
expect(() => resolveGatewayService()).toThrow("Gateway service install not supported on aix");
});
it("describes scheduled restart handoffs consistently", () => {
expect(describeGatewayServiceRestart("Gateway", { outcome: "scheduled" })).toEqual({
scheduled: true,
daemonActionResult: "scheduled",
message: "restart scheduled, gateway will restart momentarily",
progressMessage: "Gateway service restart scheduled.",
});
});
});

View File

@@ -66,6 +66,31 @@ export type GatewayService = {
readRuntime: (env: GatewayServiceEnv) => Promise<GatewayServiceRuntime>;
};
export function describeGatewayServiceRestart(
serviceNoun: string,
result: GatewayServiceRestartResult,
): {
scheduled: boolean;
daemonActionResult: "restarted" | "scheduled";
message: string;
progressMessage: string;
} {
if (result.outcome === "scheduled") {
return {
scheduled: true,
daemonActionResult: "scheduled",
message: `restart scheduled, ${serviceNoun.toLowerCase()} will restart momentarily`,
progressMessage: `${serviceNoun} service restart scheduled.`,
};
}
return {
scheduled: false,
daemonActionResult: "restarted",
message: `${serviceNoun} service restarted.`,
progressMessage: `${serviceNoun} service restarted.`,
};
}
type SupportedGatewayServicePlatform = "darwin" | "linux" | "win32";
const GATEWAY_SERVICE_REGISTRY: Record<SupportedGatewayServicePlatform, GatewayService> = {

View File

@@ -13,6 +13,13 @@ const buildGatewayInstallPlan = vi.hoisted(() =>
})),
);
const gatewayServiceInstall = vi.hoisted(() => vi.fn(async () => {}));
const gatewayServiceRestart = vi.hoisted(() =>
vi.fn<() => Promise<{ outcome: "completed" } | { outcome: "scheduled" }>>(async () => ({
outcome: "completed",
})),
);
const gatewayServiceUninstall = vi.hoisted(() => vi.fn(async () => {}));
const gatewayServiceIsLoaded = vi.hoisted(() => vi.fn(async () => false));
const resolveGatewayInstallToken = vi.hoisted(() =>
vi.fn(async () => ({
token: undefined,
@@ -56,14 +63,18 @@ vi.mock("../commands/health.js", () => ({
healthCommand: vi.fn(async () => {}),
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: vi.fn(() => ({
isLoaded: vi.fn(async () => false),
restart: vi.fn(async () => {}),
uninstall: vi.fn(async () => {}),
install: gatewayServiceInstall,
})),
}));
vi.mock("../daemon/service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../daemon/service.js")>();
return {
...actual,
resolveGatewayService: vi.fn(() => ({
isLoaded: gatewayServiceIsLoaded,
restart: gatewayServiceRestart,
uninstall: gatewayServiceUninstall,
install: gatewayServiceInstall,
})),
};
});
vi.mock("../daemon/systemd.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../daemon/systemd.js")>();
@@ -113,6 +124,11 @@ describe("finalizeOnboardingWizard", () => {
setupOnboardingShellCompletion.mockClear();
buildGatewayInstallPlan.mockClear();
gatewayServiceInstall.mockClear();
gatewayServiceIsLoaded.mockReset();
gatewayServiceIsLoaded.mockResolvedValue(false);
gatewayServiceRestart.mockReset();
gatewayServiceRestart.mockResolvedValue({ outcome: "completed" });
gatewayServiceUninstall.mockReset();
resolveGatewayInstallToken.mockClear();
isSystemdUserServiceAvailable.mockReset();
isSystemdUserServiceAvailable.mockResolvedValue(true);
@@ -244,4 +260,51 @@ describe("finalizeOnboardingWizard", () => {
expectFirstOnboardingInstallPlanCallOmitsToken();
expect(gatewayServiceInstall).toHaveBeenCalledTimes(1);
});
it("stops after a scheduled restart instead of reinstalling the service", async () => {
const progressUpdate = vi.fn();
const progressStop = vi.fn();
gatewayServiceIsLoaded.mockResolvedValue(true);
gatewayServiceRestart.mockResolvedValueOnce({ outcome: "scheduled" });
const prompter = buildWizardPrompter({
select: vi.fn(async (params: { message: string }) => {
if (params.message === "Gateway service already installed") {
return "restart";
}
return "later";
}) as never,
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: progressUpdate, stop: progressStop })),
});
await finalizeOnboardingWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: true,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime: createRuntime(),
});
expect(gatewayServiceRestart).toHaveBeenCalledTimes(1);
expect(gatewayServiceInstall).not.toHaveBeenCalled();
expect(gatewayServiceUninstall).not.toHaveBeenCalled();
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
});
});

View File

@@ -23,7 +23,7 @@ import {
} from "../commands/onboard-helpers.js";
import type { OnboardOptions } from "../commands/onboard-types.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -53,14 +53,16 @@ export async function finalizeOnboardingWizard(
const withWizardProgress = async <T>(
label: string,
options: { doneMessage?: string },
options: { doneMessage?: string | (() => string | undefined) },
work: (progress: { update: (message: string) => void }) => Promise<T>,
): Promise<T> => {
const progress = prompter.progress(label);
try {
return await work(progress);
} finally {
progress.stop(options.doneMessage);
progress.stop(
typeof options.doneMessage === "function" ? options.doneMessage() : options.doneMessage,
);
}
};
@@ -128,6 +130,7 @@ export async function finalizeOnboardingWizard(
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
let restartWasScheduled = false;
if (loaded) {
const action = await prompter.select({
message: "Gateway service already installed",
@@ -138,15 +141,19 @@ export async function finalizeOnboardingWizard(
],
});
if (action === "restart") {
let restartDoneMessage = "Gateway service restarted.";
await withWizardProgress(
"Gateway service",
{ doneMessage: "Gateway service restarted." },
{ doneMessage: () => restartDoneMessage },
async (progress) => {
progress.update("Restarting Gateway service…");
await service.restart({
const restartResult = await service.restart({
env: process.env,
stdout: process.stdout,
});
const restartStatus = describeGatewayServiceRestart("Gateway", restartResult);
restartDoneMessage = restartStatus.progressMessage;
restartWasScheduled = restartStatus.scheduled;
},
);
} else if (action === "reinstall") {
@@ -161,7 +168,10 @@ export async function finalizeOnboardingWizard(
}
}
if (!loaded || (loaded && !(await service.isLoaded({ env: process.env })))) {
if (
!loaded ||
(!restartWasScheduled && loaded && !(await service.isLoaded({ env: process.env })))
) {
const progress = prompter.progress("Gateway service");
let installError: string | null = null;
try {