fix: improve gateway diagnostics

This commit is contained in:
Peter Steinberger
2026-01-08 02:28:21 +01:00
parent 02ad9eccad
commit 61f5ed8bb7
21 changed files with 1037 additions and 63 deletions

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js";
@@ -39,3 +40,36 @@ export async function shouldSuggestMemorySystem(
return true;
}
export type LegacyWorkspaceDetection = {
activeWorkspace: string;
legacyDirs: string[];
};
export function detectLegacyWorkspaceDirs(params: {
workspaceDir: string;
homedir?: () => string;
exists?: (value: string) => boolean;
}): LegacyWorkspaceDetection {
const homedir = params.homedir ?? os.homedir;
const exists = params.exists ?? fs.existsSync;
const home = homedir();
const activeWorkspace = path.resolve(params.workspaceDir);
const candidates = [path.join(home, "clawdis"), path.join(home, "clawdbot")];
const legacyDirs = candidates.filter((candidate) => {
if (!exists(candidate)) return false;
return path.resolve(candidate) !== activeWorkspace;
});
return { activeWorkspace, legacyDirs };
}
export function formatLegacyWorkspaceWarning(
detection: LegacyWorkspaceDetection,
): string {
return [
"Legacy workspace directories detected (may contain old agent files):",
...detection.legacyDirs.map((dir) => `- ${dir}`),
`Active workspace: ${detection.activeWorkspace}`,
"If unused, archive or move to Trash (e.g. trash ~/clawdis).",
].join("\n");
}

View File

@@ -157,6 +157,7 @@ vi.mock("../daemon/service.js", () => ({
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: vi.fn(),
readRuntime: vi.fn().mockResolvedValue({ status: "running" }),
}),
}));
@@ -492,6 +493,52 @@ describe("doctor", () => {
),
).toBe(true);
});
it("warns when legacy workspace directories exist", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {
agent: { workspace: "/Users/steipete/clawd" },
},
issues: [],
legacyIssues: [],
});
note.mockClear();
const homedirSpy = vi
.spyOn(os, "homedir")
.mockReturnValue("/Users/steipete");
const realExists = fs.existsSync;
const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((value) => {
if (value === "/Users/steipete/clawdis") return true;
return realExists(value as never);
});
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await doctorCommand(runtime, { nonInteractive: true });
expect(
note.mock.calls.some(
([message, title]) =>
title === "Legacy workspace" &&
typeof message === "string" &&
message.includes("/Users/steipete/clawdis"),
),
).toBe(true);
homedirSpy.mockRestore();
existsSpy.mockRestore();
});
it("falls back to legacy sandbox image when missing", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",

View File

@@ -5,10 +5,14 @@ import {
CONFIG_PATH_CLAWDBOT,
migrateLegacyConfig,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -36,6 +40,8 @@ import {
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
import {
detectLegacyWorkspaceDirs,
formatLegacyWorkspaceWarning,
MEMORY_SYSTEM_PROMPT,
shouldSuggestMemorySystem,
} from "./doctor-workspace.js";
@@ -51,6 +57,62 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local";
}
function formatRuntimeSummary(
runtime: GatewayServiceRuntime | undefined,
): string | null {
if (!runtime) return null;
const status = runtime.status ?? "unknown";
const details: string[] = [];
if (runtime.pid) details.push(`pid ${runtime.pid}`);
if (runtime.state && runtime.state.toLowerCase() !== status) {
details.push(`state ${runtime.state}`);
}
if (runtime.subState) details.push(`sub ${runtime.subState}`);
if (runtime.lastExitStatus !== undefined) {
details.push(`last exit ${runtime.lastExitStatus}`);
}
if (runtime.lastExitReason) {
details.push(`reason ${runtime.lastExitReason}`);
}
if (runtime.lastRunResult) {
details.push(`last run ${runtime.lastRunResult}`);
}
if (runtime.lastRunTime) {
details.push(`last run time ${runtime.lastRunTime}`);
}
if (runtime.detail) details.push(runtime.detail);
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
}
function buildGatewayRuntimeHints(
runtime: GatewayServiceRuntime | undefined,
): string[] {
const hints: string[] = [];
if (!runtime) return hints;
if (runtime.cachedLabel && process.platform === "darwin") {
hints.push(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
);
}
if (runtime.status === "stopped") {
hints.push(
"Service is loaded but not running (likely exited immediately).",
);
if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(process.env);
hints.push(`Logs: ${logs.stdoutPath}`);
hints.push(`Errors: ${logs.stderrPath}`);
} else if (process.platform === "linux") {
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
);
} else if (process.platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
}
}
return hints;
}
export async function doctorCommand(
runtime: RuntimeEnv = defaultRuntime,
options: DoctorOptions = {},
@@ -168,6 +230,10 @@ export async function doctorCommand(
const workspaceDir = resolveUserPath(
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
);
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
if (legacyWorkspace.legacyDirs.length > 0) {
note(formatLegacyWorkspaceWarning(legacyWorkspace), "Legacy workspace");
}
const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg });
note(
[
@@ -198,11 +264,29 @@ export async function doctorCommand(
}
if (!healthOk) {
if (resolveMode(cfg) === "local") {
const port = resolveGatewayPort(cfg, process.env);
const diagnostics = await inspectPortUsage(port);
if (diagnostics.status === "busy") {
note(formatPortDiagnostics(diagnostics).join("\n"), "Gateway port");
}
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
if (!loaded) {
note("Gateway daemon not installed.", "Gateway");
} else {
const serviceRuntime = await service
.readRuntime(process.env)
.catch(() => undefined);
const summary = formatRuntimeSummary(serviceRuntime);
const hints = buildGatewayRuntimeHints(serviceRuntime);
if (summary || hints.length > 0) {
const lines = [];
if (summary) lines.push(`Runtime: ${summary}`);
lines.push(...hints);
note(lines.join("\n"), "Gateway");
}
if (process.platform === "darwin") {
note(
`LaunchAgent loaded; stopping requires "clawdbot gateway stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`,