refactor: align pairing replies, daemon hints, and feishu mention policy

This commit is contained in:
Peter Steinberger
2026-03-25 04:21:51 -07:00
parent 524004ff32
commit b7f2b0d7b9
15 changed files with 436 additions and 210 deletions

View File

@@ -233,10 +233,23 @@ describe("runServiceRestart token drift", () => {
opts: { json: true },
});
const payload = readJsonLog<{ ok?: boolean; result?: string; hints?: string[] }>();
const payload = readJsonLog<{
ok?: boolean;
result?: string;
hints?: string[];
hintItems?: Array<{ kind: string; text: string }>;
}>();
expect(payload.ok).toBe(true);
expect(payload.result).toBe("not-loaded");
expect(payload.hints).toEqual(expect.arrayContaining(["openclaw gateway install"]));
expect(payload.hintItems).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "install",
text: "openclaw gateway install",
}),
]),
);
expect(service.restart).not.toHaveBeenCalled();
});
});

View File

@@ -14,10 +14,8 @@ import { defaultRuntime } from "../../runtime.js";
import { resolveGatewayTokenForDriftCheck } from "./gateway-token-drift.js";
import {
buildDaemonServiceSnapshot,
createNullWriter,
type DaemonAction,
createDaemonActionContext,
type DaemonActionResponse,
emitDaemonActionJson,
} from "./response.js";
import { filterContainerGenericHints } from "./shared.js";
@@ -58,28 +56,9 @@ async function maybeAugmentSystemdHints(hints: string[]): Promise<string[]> {
];
}
function createActionIO(params: { action: DaemonAction; json: boolean }) {
const stdout = params.json ? createNullWriter() : process.stdout;
const emit = (payload: Omit<DaemonActionResponse, "action">) => {
if (!params.json) {
return;
}
emitDaemonActionJson({ action: params.action, ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (params.json) {
emit({ ok: false, error: message, hints });
} else {
defaultRuntime.error(message);
}
defaultRuntime.exit(1);
};
return { stdout, emit, fail };
}
function emitActionMessage(params: {
json: boolean;
emit: ReturnType<typeof createActionIO>["emit"];
emit: ReturnType<typeof createDaemonActionContext>["emit"];
payload: Omit<DaemonActionResponse, "action">;
}) {
params.emit(params.payload);
@@ -94,7 +73,7 @@ async function handleServiceNotLoaded(params: {
loaded: boolean;
renderStartHints: () => string[];
json: boolean;
emit: ReturnType<typeof createActionIO>["emit"];
emit: ReturnType<typeof createDaemonActionContext>["emit"];
}) {
const hints = filterContainerGenericHints(
await maybeAugmentSystemdHints(params.renderStartHints()),
@@ -117,7 +96,7 @@ async function handleServiceNotLoaded(params: {
async function resolveServiceLoadedOrFail(params: {
serviceNoun: string;
service: GatewayService;
fail: ReturnType<typeof createActionIO>["fail"];
fail: ReturnType<typeof createDaemonActionContext>["fail"];
}): Promise<boolean | null> {
try {
return await params.service.isLoaded({ env: process.env });
@@ -158,7 +137,7 @@ export async function runServiceUninstall(params: {
assertNotLoadedAfterUninstall: boolean;
}) {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createActionIO({ action: "uninstall", json });
const { stdout, emit, fail } = createDaemonActionContext({ action: "uninstall", json });
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; service uninstall is disabled.");
@@ -209,7 +188,7 @@ export async function runServiceStart(params: {
opts?: DaemonLifecycleOptions;
}) {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createActionIO({ action: "start", json });
const { stdout, emit, fail } = createDaemonActionContext({ action: "start", json });
if (
(await resolveServiceLoadedOrFail({
@@ -279,7 +258,7 @@ export async function runServiceStop(params: {
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
}) {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createActionIO({ action: "stop", json });
const { stdout, emit, fail } = createDaemonActionContext({ action: "stop", json });
const loaded = await resolveServiceLoadedOrFail({
serviceNoun: params.serviceNoun,
@@ -350,7 +329,7 @@ export async function runServiceRestart(params: {
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
}): Promise<boolean> {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createActionIO({ action: "restart", json });
const { stdout, emit, fail } = createDaemonActionContext({ action: "restart", json });
const warnings: string[] = [];
let handledNotLoaded: NotLoadedActionResult | null = null;
const emitScheduledRestart = (

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { buildDaemonHintItems } from "./response.js";
describe("buildDaemonHintItems", () => {
it("classifies common daemon hint kinds", () => {
expect(
buildDaemonHintItems([
"openclaw gateway install",
"Restart the container or the service that manages it for openclaw-demo-container.",
"systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.",
"On a headless server (SSH/no desktop session): run `sudo loginctl enable-linger $(whoami)` to persist your systemd user session across logins.",
"If you're in a container, run the gateway in the foreground instead of `openclaw gateway`.",
"WSL2 needs systemd enabled: edit /etc/wsl.conf with [boot]\\nsystemd=true",
]),
).toEqual([
{ kind: "install", text: "openclaw gateway install" },
{
kind: "container-restart",
text: "Restart the container or the service that manages it for openclaw-demo-container.",
},
{
kind: "systemd-unavailable",
text: "systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.",
},
{
kind: "systemd-headless",
text: "On a headless server (SSH/no desktop session): run `sudo loginctl enable-linger $(whoami)` to persist your systemd user session across logins.",
},
{
kind: "container-foreground",
text: "If you're in a container, run the gateway in the foreground instead of `openclaw gateway`.",
},
{
kind: "wsl-systemd",
text: "WSL2 needs systemd enabled: edit /etc/wsl.conf with [boot]\\nsystemd=true",
},
]);
});
});

View File

@@ -4,6 +4,20 @@ import { defaultRuntime } from "../../runtime.js";
export type DaemonAction = "install" | "uninstall" | "start" | "stop" | "restart";
export type DaemonHintKind =
| "install"
| "container-restart"
| "container-foreground"
| "systemd-unavailable"
| "systemd-headless"
| "wsl-systemd"
| "generic";
export type DaemonHintItem = {
kind: DaemonHintKind;
text: string;
};
export type DaemonActionResponse = {
ok: boolean;
action: DaemonAction;
@@ -11,6 +25,7 @@ export type DaemonActionResponse = {
message?: string;
error?: string;
hints?: string[];
hintItems?: DaemonHintItem[];
warnings?: string[];
service?: {
label: string;
@@ -24,6 +39,42 @@ export function emitDaemonActionJson(payload: DaemonActionResponse) {
defaultRuntime.writeJson(payload);
}
function classifyDaemonHintText(text: string): DaemonHintKind {
if (text.includes("openclaw gateway install") || text.startsWith("Service not installed. Run:")) {
return "install";
}
if (text.startsWith("Restart the container or the service that manages it for ")) {
return "container-restart";
}
if (text.startsWith("systemd user services are unavailable;")) {
return "systemd-unavailable";
}
if (
text.startsWith("On a headless server (SSH/no desktop session):") ||
text.startsWith("Also ensure XDG_RUNTIME_DIR is set:")
) {
return "systemd-headless";
}
if (text.startsWith("If you're in a container, run the gateway in the foreground instead of")) {
return "container-foreground";
}
if (
text.startsWith("WSL2 needs systemd enabled:") ||
text.startsWith("Then run: wsl --shutdown") ||
text.startsWith("Verify: systemctl --user status")
) {
return "wsl-systemd";
}
return "generic";
}
export function buildDaemonHintItems(hints: string[] | undefined): DaemonHintItem[] | undefined {
if (!hints?.length) {
return undefined;
}
return hints.map((text) => ({ kind: classifyDaemonHintText(text), text }));
}
export function buildDaemonServiceSnapshot(service: GatewayService, loaded: boolean) {
return {
label: service.label,
@@ -56,6 +107,7 @@ export function createDaemonActionContext(params: { action: DaemonAction; json:
emitDaemonActionJson({
action: params.action,
...payload,
hintItems: payload.hintItems ?? buildDaemonHintItems(payload.hints),
warnings: payload.warnings ?? (warnings.length ? warnings : undefined),
});
};