mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(cli): handle scheduled gateway restarts consistently
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
194
src/commands/doctor-gateway-daemon-flow.test.ts
Normal file
194
src/commands/doctor-gateway-daemon-flow.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user