feat: add logs cli and restart hints

This commit is contained in:
Peter Steinberger
2026-01-08 06:48:28 +00:00
parent c9e07616c7
commit d1ceb3aa60
15 changed files with 291 additions and 32 deletions

View File

@@ -24,7 +24,10 @@ import {
formatUsageSummaryLine,
loadProviderUsageSummary,
} from "../../infra/provider-usage.js";
import { triggerClawdbotRestart } from "../../infra/restart.js";
import {
scheduleGatewaySigusr1Restart,
triggerClawdbotRestart,
} from "../../infra/restart.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
@@ -360,11 +363,32 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0;
if (hasSigusr1Listener) {
scheduleGatewaySigusr1Restart({ reason: "/restart" });
return {
shouldContinue: false,
reply: {
text: "⚙️ Restarting clawdbot in-process (SIGUSR1); back in a few seconds.",
},
};
}
const restartMethod = triggerClawdbotRestart();
if (!restartMethod.ok) {
const detail = restartMethod.detail
? ` Details: ${restartMethod.detail}`
: "";
return {
shouldContinue: false,
reply: {
text: `⚠️ Restart failed (${restartMethod.method}).${detail}`,
},
};
}
return {
shouldContinue: false,
reply: {
text: `⚙️ Restarting clawdbot via ${restartMethod}; give me a few seconds to come back online.`,
text: `⚙️ Restarting clawdbot via ${restartMethod.method}; give me a few seconds to come back online.`,
},
};
}

View File

@@ -28,6 +28,7 @@ import {
type PortListener,
type PortUsageStatus,
} from "../infra/ports.js";
import { getResolvedLoggerSettings } from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import { createDefaultDeps } from "./deps.js";
import { withProgress } from "./progress.js";
@@ -171,11 +172,24 @@ function renderRuntimeHints(
): string[] {
if (!runtime) return [];
const hints: string[] = [];
const fileLog = (() => {
try {
return getResolvedLoggerSettings().file;
} catch {
return null;
}
})();
if (runtime.missingUnit) {
hints.push("Service not installed. Run: clawdbot daemon install");
if (fileLog) hints.push(`File logs: ${fileLog}`);
return hints;
}
if (runtime.status === "stopped") {
if (fileLog) hints.push(`File logs: ${fileLog}`);
if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(process.env);
hints.push(`Logs: ${logs.stdoutPath}`);
hints.push(`Errors: ${logs.stderrPath}`);
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (process.platform === "linux") {
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
@@ -188,17 +202,22 @@ function renderRuntimeHints(
}
function renderGatewayServiceStartHints(): string[] {
const base = ["clawdbot daemon install", "clawdbot gateway"];
switch (process.platform) {
case "darwin":
return [
...base,
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
];
case "linux":
return [`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`];
return [
...base,
`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
];
case "win32":
return [`schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
return [...base, `schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
default:
return [];
return base;
}
}
@@ -261,6 +280,12 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
defaultRuntime.log(
`Service: ${service.label} (${service.loaded ? service.loadedText : service.notLoadedText})`,
);
try {
const logFile = getResolvedLoggerSettings().file;
defaultRuntime.log(`File logs: ${logFile}`);
} catch {
// ignore missing config/log resolution
}
if (service.command?.programArguments?.length) {
defaultRuntime.log(
`Command: ${service.command.programArguments.join(" ")}`,
@@ -280,7 +305,12 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
defaultRuntime.error(`RPC probe: failed (${rpc.error})`);
}
}
if (service.loaded && service.runtime?.status === "stopped") {
if (service.runtime?.missingUnit) {
defaultRuntime.error("Service unit not found.");
for (const hint of renderRuntimeHints(service.runtime)) {
defaultRuntime.error(hint);
}
} else if (service.loaded && service.runtime?.status === "stopped") {
defaultRuntime.error(
"Service is loaded but not running (likely exited immediately).",
);
@@ -292,6 +322,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
defaultRuntime.error(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
);
defaultRuntime.error("Then reinstall: clawdbot daemon install");
}
if (status.port && shouldReportPortUsage(status.port.status, rpc?.ok)) {
for (const line of formatPortDiagnostics({

96
src/cli/logs-cli.ts Normal file
View File

@@ -0,0 +1,96 @@
import { setTimeout as delay } from "node:timers/promises";
import type { Command } from "commander";
import { defaultRuntime } from "../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
type LogsTailPayload = {
file?: string;
cursor?: number;
size?: number;
lines?: string[];
truncated?: boolean;
reset?: boolean;
};
type LogsCliOptions = {
limit?: string;
maxBytes?: string;
follow?: boolean;
interval?: string;
json?: boolean;
url?: string;
token?: string;
timeout?: string;
expectFinal?: boolean;
};
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
async function fetchLogs(
opts: LogsCliOptions,
cursor: number | undefined,
): Promise<LogsTailPayload> {
const limit = parsePositiveInt(opts.limit, 200);
const maxBytes = parsePositiveInt(opts.maxBytes, 250_000);
const payload = await callGatewayFromCli("logs.tail", opts, {
cursor,
limit,
maxBytes,
});
if (!payload || typeof payload !== "object") {
throw new Error("Unexpected logs.tail response");
}
return payload as LogsTailPayload;
}
export function registerLogsCli(program: Command) {
const logs = program
.command("logs")
.description("Tail gateway file logs via RPC")
.option("--limit <n>", "Max lines to return", "200")
.option("--max-bytes <n>", "Max bytes to read", "250000")
.option("--follow", "Follow log output", false)
.option("--interval <ms>", "Polling interval in ms", "1000")
.option("--json", "Emit JSON payloads", false);
addGatewayClientOptions(logs);
logs.action(async (opts: LogsCliOptions) => {
const interval = parsePositiveInt(opts.interval, 1000);
let cursor: number | undefined;
let first = true;
while (true) {
const payload = await fetchLogs(opts, cursor);
const lines = Array.isArray(payload.lines) ? payload.lines : [];
if (opts.json) {
defaultRuntime.log(JSON.stringify(payload, null, 2));
} else {
if (first && payload.file) {
defaultRuntime.log(`Log file: ${payload.file}`);
}
for (const line of lines) {
defaultRuntime.log(line);
}
if (payload.truncated) {
defaultRuntime.error("Log tail truncated (increase --max-bytes).");
}
if (payload.reset) {
defaultRuntime.error("Log cursor reset (file rotated).");
}
}
cursor =
typeof payload.cursor === "number" && Number.isFinite(payload.cursor)
? payload.cursor
: cursor;
first = false;
if (!opts.follow) return;
await delay(interval);
}
});
}

View File

@@ -37,6 +37,7 @@ import { registerDnsCli } from "./dns-cli.js";
import { registerDocsCli } from "./docs-cli.js";
import { registerGatewayCli } from "./gateway-cli.js";
import { registerHooksCli } from "./hooks-cli.js";
import { registerLogsCli } from "./logs-cli.js";
import { registerModelsCli } from "./models-cli.js";
import { registerNodesCli } from "./nodes-cli.js";
import { registerPairingCli } from "./pairing-cli.js";
@@ -616,6 +617,7 @@ Examples:
registerDaemonCli(program);
registerGatewayCli(program);
registerLogsCli(program);
registerModelsCli(program);
registerNodesCli(program);
registerTuiCli(program);

View File

@@ -1,6 +1,7 @@
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import { getResolvedLoggerSettings } from "../logging.js";
type RuntimeHintOptions = {
platform?: NodeJS.Platform;
@@ -42,19 +43,33 @@ export function buildGatewayRuntimeHints(
if (!runtime) return hints;
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const fileLog = (() => {
try {
return getResolvedLoggerSettings().file;
} catch {
return null;
}
})();
if (runtime.cachedLabel && platform === "darwin") {
hints.push(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
);
hints.push("Then reinstall: clawdbot daemon install");
}
if (runtime.missingUnit) {
hints.push("Service not installed. Run: clawdbot daemon install");
if (fileLog) hints.push(`File logs: ${fileLog}`);
return hints;
}
if (runtime.status === "stopped") {
hints.push(
"Service is loaded but not running (likely exited immediately).",
);
if (fileLog) hints.push(`File logs: ${fileLog}`);
if (platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
hints.push(`Logs: ${logs.stdoutPath}`);
hints.push(`Errors: ${logs.stderrPath}`);
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (platform === "linux") {
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",

View File

@@ -1,38 +1,97 @@
import { spawnSync } from "node:child_process";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
} from "../daemon/constants.js";
const DEFAULT_LAUNCHD_LABEL = "com.clawdbot.mac";
const DEFAULT_SYSTEMD_UNIT = "clawdbot-gateway.service";
export type RestartAttempt = {
ok: boolean;
method: "launchctl" | "systemd" | "supervisor";
detail?: string;
tried?: string[];
};
export function triggerClawdbotRestart():
| "launchctl"
| "systemd"
| "supervisor" {
function formatSpawnDetail(result: {
error?: unknown;
status?: number | null;
stdout?: string | Buffer | null;
stderr?: string | Buffer | null;
}): string {
const clean = (value: string | Buffer | null | undefined) => {
const text =
typeof value === "string"
? value
: value
? value.toString()
: "";
return text.replace(/\s+/g, " ").trim();
};
if (result.error) return String(result.error);
const stderr = clean(result.stderr);
if (stderr) return stderr;
const stdout = clean(result.stdout);
if (stdout) return stdout;
if (typeof result.status === "number") return `exit ${result.status}`;
return "unknown error";
}
function normalizeSystemdUnit(raw?: string): string {
const unit = raw?.trim();
if (!unit) return `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
return unit.endsWith(".service") ? unit : `${unit}.service`;
}
export function triggerClawdbotRestart(): RestartAttempt {
const tried: string[] = [];
if (process.platform !== "darwin") {
if (process.platform === "linux") {
const unit = process.env.CLAWDBOT_SYSTEMD_UNIT || DEFAULT_SYSTEMD_UNIT;
const userRestart = spawnSync("systemctl", ["--user", "restart", unit], {
stdio: "ignore",
const unit = normalizeSystemdUnit(process.env.CLAWDBOT_SYSTEMD_UNIT);
const userArgs = ["--user", "restart", unit];
tried.push(`systemctl ${userArgs.join(" ")}`);
const userRestart = spawnSync("systemctl", userArgs, {
encoding: "utf8",
});
if (!userRestart.error && userRestart.status === 0) {
return "systemd";
return { ok: true, method: "systemd", tried };
}
const systemRestart = spawnSync("systemctl", ["restart", unit], {
stdio: "ignore",
const systemArgs = ["restart", unit];
tried.push(`systemctl ${systemArgs.join(" ")}`);
const systemRestart = spawnSync("systemctl", systemArgs, {
encoding: "utf8",
});
if (!systemRestart.error && systemRestart.status === 0) {
return "systemd";
return { ok: true, method: "systemd", tried };
}
return "systemd";
const detail = [
`user: ${formatSpawnDetail(userRestart)}`,
`system: ${formatSpawnDetail(systemRestart)}`,
].join("; ");
return { ok: false, method: "systemd", detail, tried };
}
return "supervisor";
return {
ok: false,
method: "supervisor",
detail: "unsupported platform restart",
};
}
const label = process.env.CLAWDBOT_LAUNCHD_LABEL || DEFAULT_LAUNCHD_LABEL;
const label =
process.env.CLAWDBOT_LAUNCHD_LABEL || GATEWAY_LAUNCH_AGENT_LABEL;
const uid =
typeof process.getuid === "function" ? process.getuid() : undefined;
const target = uid !== undefined ? `gui/${uid}/${label}` : label;
spawnSync("launchctl", ["kickstart", "-k", target], { stdio: "ignore" });
return "launchctl";
const args = ["kickstart", "-k", target];
tried.push(`launchctl ${args.join(" ")}`);
const res = spawnSync("launchctl", args, { encoding: "utf8" });
if (!res.error && res.status === 0) {
return { ok: true, method: "launchctl", tried };
}
return {
ok: false,
method: "launchctl",
detail: formatSpawnDetail(res),
tried,
};
}
export type ScheduledRestart = {