Skip to content

Commit 258a214

Browse files
committed
refactor: centralize daemon service start state flow
1 parent 5dec3dd commit 258a214

File tree

7 files changed

+290
-84
lines changed

7 files changed

+290
-84
lines changed

src/cli/daemon-cli/lifecycle-core.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,38 @@ describe("runServiceRestart token drift", () => {
205205
opts: { json: true },
206206
});
207207

208-
expect(service.isLoaded).toHaveBeenCalledTimes(1);
208+
expect(service.isLoaded).toHaveBeenCalled();
209209
const payload = readJsonLog<{ result?: string; message?: string }>();
210210
expect(payload.result).toBe("scheduled");
211211
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
212212
});
213+
214+
it("fails start when restarting a stopped installed service errors", async () => {
215+
service.isLoaded.mockResolvedValue(false);
216+
service.restart.mockRejectedValue(new Error("launchctl kickstart failed: permission denied"));
217+
218+
await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1");
219+
220+
const payload = readJsonLog<{ ok?: boolean; error?: string }>();
221+
expect(payload.ok).toBe(false);
222+
expect(payload.error).toContain("launchctl kickstart failed: permission denied");
223+
});
224+
225+
it("falls back to not-loaded hints when start finds no install artifacts", async () => {
226+
service.isLoaded.mockResolvedValue(false);
227+
service.readCommand.mockResolvedValue(null);
228+
229+
await runServiceStart({
230+
serviceNoun: "Gateway",
231+
service,
232+
renderStartHints: () => ["openclaw gateway install"],
233+
opts: { json: true },
234+
});
235+
236+
const payload = readJsonLog<{ ok?: boolean; result?: string; hints?: string[] }>();
237+
expect(payload.ok).toBe(true);
238+
expect(payload.result).toBe("not-loaded");
239+
expect(payload.hints).toEqual(["openclaw gateway install"]);
240+
expect(service.restart).not.toHaveBeenCalled();
241+
});
213242
});

src/cli/daemon-cli/lifecycle-core.ts

Lines changed: 51 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { formatConfigIssueLines } from "../../config/issue-format.js";
44
import { resolveIsNixMode } from "../../config/paths.js";
55
import { checkTokenDrift } from "../../daemon/service-audit.js";
66
import type { GatewayServiceRestartResult } from "../../daemon/service-types.js";
7-
import { describeGatewayServiceRestart } from "../../daemon/service.js";
7+
import { describeGatewayServiceRestart, startGatewayService } from "../../daemon/service.js";
88
import type { GatewayService } from "../../daemon/service.js";
99
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
1010
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
@@ -77,6 +77,17 @@ function createActionIO(params: { action: DaemonAction; json: boolean }) {
7777
return { stdout, emit, fail };
7878
}
7979

80+
function emitActionMessage(params: {
81+
json: boolean;
82+
emit: ReturnType<typeof createActionIO>["emit"];
83+
payload: Omit<DaemonActionResponse, "action">;
84+
}) {
85+
params.emit(params.payload);
86+
if (!params.json && params.payload.message) {
87+
defaultRuntime.log(params.payload.message);
88+
}
89+
}
90+
8091
async function handleServiceNotLoaded(params: {
8192
serviceNoun: string;
8293
service: GatewayService;
@@ -200,12 +211,13 @@ export async function runServiceStart(params: {
200211
const json = Boolean(params.opts?.json);
201212
const { stdout, emit, fail } = createActionIO({ action: "start", json });
202213

203-
const loaded = await resolveServiceLoadedOrFail({
204-
serviceNoun: params.serviceNoun,
205-
service: params.service,
206-
fail,
207-
});
208-
if (loaded === null) {
214+
if (
215+
(await resolveServiceLoadedOrFail({
216+
serviceNoun: params.serviceNoun,
217+
service: params.service,
218+
fail,
219+
})) === null
220+
) {
209221
return;
210222
}
211223
// Pre-flight config validation (#35862) — run for both loaded and not-loaded
@@ -219,71 +231,45 @@ export async function runServiceStart(params: {
219231
return;
220232
}
221233
}
222-
if (!loaded) {
223-
// Service was stopped (e.g. `gateway stop` booted out the LaunchAgent).
224-
// Attempt a restart, which handles re-bootstrapping the service. Without
225-
// this, `start` after `stop` just prints hints and does nothing (#53878).
226-
try {
227-
const restartResult = await params.service.restart({ env: process.env, stdout });
228-
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
229-
const postLoaded = await params.service.isLoaded({ env: process.env }).catch(() => true);
230-
emit({
231-
ok: true,
232-
result: restartStatus.daemonActionResult,
233-
message: restartStatus.message,
234-
service: buildDaemonServiceSnapshot(params.service, postLoaded),
235-
});
236-
if (!json) {
237-
defaultRuntime.log(restartStatus.message);
238-
}
239-
return;
240-
} catch {
241-
// Bootstrap failed (e.g. plist was deleted, not just booted out).
242-
// Fall through to the not-loaded hints.
234+
try {
235+
const startResult = await startGatewayService(params.service, { env: process.env, stdout });
236+
if (startResult.outcome === "missing-install") {
243237
await handleServiceNotLoaded({
244238
serviceNoun: params.serviceNoun,
245239
service: params.service,
246-
loaded,
240+
loaded: startResult.state.loaded,
247241
renderStartHints: params.renderStartHints,
248242
json,
249243
emit,
250244
});
251245
return;
252246
}
253-
}
254-
255-
try {
256-
const restartResult = await params.service.restart({ env: process.env, stdout });
257-
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult);
258-
if (restartStatus.scheduled) {
259-
emit({
260-
ok: true,
261-
result: restartStatus.daemonActionResult,
262-
message: restartStatus.message,
263-
service: buildDaemonServiceSnapshot(params.service, loaded),
247+
if (startResult.outcome === "scheduled") {
248+
const restartStatus = describeGatewayServiceRestart(params.serviceNoun, {
249+
outcome: "scheduled",
250+
});
251+
emitActionMessage({
252+
json,
253+
emit,
254+
payload: {
255+
ok: true,
256+
result: "scheduled",
257+
message: restartStatus.message,
258+
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
259+
},
264260
});
265-
if (!json) {
266-
defaultRuntime.log(restartStatus.message);
267-
}
268261
return;
269262
}
263+
emit({
264+
ok: true,
265+
result: "started",
266+
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
267+
});
270268
} catch (err) {
271269
const hints = params.renderStartHints();
272270
fail(`${params.serviceNoun} start failed: ${String(err)}`, hints);
273271
return;
274272
}
275-
276-
let started = true;
277-
try {
278-
started = await params.service.isLoaded({ env: process.env });
279-
} catch {
280-
started = true;
281-
}
282-
emit({
283-
ok: true,
284-
result: "started",
285-
service: buildDaemonServiceSnapshot(params.service, started),
286-
});
287273
}
288274

289275
export async function runServiceStop(params: {
@@ -371,16 +357,17 @@ export async function runServiceRestart(params: {
371357
restartStatus: ReturnType<typeof describeGatewayServiceRestart>,
372358
serviceLoaded: boolean,
373359
) => {
374-
emit({
375-
ok: true,
376-
result: restartStatus.daemonActionResult,
377-
message: restartStatus.message,
378-
service: buildDaemonServiceSnapshot(params.service, serviceLoaded),
379-
warnings: warnings.length ? warnings : undefined,
360+
emitActionMessage({
361+
json,
362+
emit,
363+
payload: {
364+
ok: true,
365+
result: restartStatus.daemonActionResult,
366+
message: restartStatus.message,
367+
service: buildDaemonServiceSnapshot(params.service, serviceLoaded),
368+
warnings: warnings.length ? warnings : undefined,
369+
},
380370
});
381-
if (!json) {
382-
defaultRuntime.log(restartStatus.message);
383-
}
384371
return true;
385372
};
386373

src/cli/daemon-cli/test-helpers/lifecycle-core-harness.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ export function resetLifecycleServiceMocks() {
4242
service.stage.mockClear();
4343
service.isLoaded.mockClear();
4444
service.readCommand.mockClear();
45+
service.readRuntime.mockClear();
4546
service.restart.mockClear();
4647
service.isLoaded.mockResolvedValue(true);
4748
service.readCommand.mockResolvedValue({ programArguments: [], environment: {} });
49+
service.readRuntime.mockResolvedValue({ status: "running" });
4850
service.restart.mockResolvedValue({ outcome: "completed" });
4951
}
5052

src/commands/status.service-summary.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
2-
import type { GatewayService } from "../daemon/service.js";
2+
import { readGatewayServiceState, type GatewayService } from "../daemon/service.js";
33

44
export type ServiceStatusSummary = {
55
label: string;
@@ -16,33 +16,23 @@ export async function readServiceStatusSummary(
1616
fallbackLabel: string,
1717
): Promise<ServiceStatusSummary> {
1818
try {
19-
const command = await service.readCommand(process.env).catch(() => null);
20-
const serviceEnv = command?.environment
21-
? ({
22-
...process.env,
23-
...command.environment,
24-
} satisfies NodeJS.ProcessEnv)
25-
: process.env;
26-
const [loaded, runtime] = await Promise.all([
27-
service.isLoaded({ env: serviceEnv }).catch(() => false),
28-
service.readRuntime(serviceEnv).catch(() => undefined),
29-
]);
30-
const managedByOpenClaw = command != null;
31-
const externallyManaged = !managedByOpenClaw && runtime?.status === "running";
19+
const state = await readGatewayServiceState(service, { env: process.env });
20+
const managedByOpenClaw = state.installed;
21+
const externallyManaged = !managedByOpenClaw && state.running;
3222
const installed = managedByOpenClaw || externallyManaged;
3323
const loadedText = externallyManaged
3424
? "running (externally managed)"
35-
: loaded
25+
: state.loaded
3626
? service.loadedText
3727
: service.notLoadedText;
3828
return {
3929
label: service.label,
4030
installed,
41-
loaded,
31+
loaded: state.loaded,
4232
managedByOpenClaw,
4333
externallyManaged,
4434
loadedText,
45-
runtime,
35+
runtime: state.runtime,
4636
};
4737
} catch {
4838
return {

src/daemon/service-types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { GatewayServiceRuntime } from "./service-runtime.js";
2+
13
export type GatewayServiceEnv = Record<string, string | undefined>;
24

35
export type GatewayServiceInstallArgs = {
@@ -35,6 +37,20 @@ export type GatewayServiceCommandConfig = {
3537
sourcePath?: string;
3638
};
3739

40+
export type GatewayServiceState = {
41+
installed: boolean;
42+
loaded: boolean;
43+
running: boolean;
44+
env: GatewayServiceEnv;
45+
command: GatewayServiceCommandConfig | null;
46+
runtime?: GatewayServiceRuntime;
47+
};
48+
49+
export type GatewayServiceStartResult =
50+
| { outcome: "started"; state: GatewayServiceState }
51+
| { outcome: "scheduled"; state: GatewayServiceState }
52+
| { outcome: "missing-install"; state: GatewayServiceState };
53+
3854
export type GatewayServiceRenderArgs = {
3955
description?: string;
4056
programArguments: string[];

0 commit comments

Comments
 (0)