diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index 6355c0206c6..edb653a740e 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -28,11 +28,13 @@ vi.mock("../../daemon/inspect.js", () => ({ renderGatewayServiceCleanupHints: () => [], })); -vi.mock("../../daemon/launchd.js", () => ({ +vi.mock("../../daemon/restart-logs.js", () => ({ resolveGatewayLogPaths: () => ({ + logDir: "/tmp", stdoutPath: "/tmp/gateway.out.log", stderrPath: "/tmp/gateway.err.log", }), + resolveGatewayRestartLogPath: () => "/tmp/gateway-restart.log", })); vi.mock("../../daemon/systemd-hints.js", () => ({ diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 76c5c7f71ff..391c26c8812 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -4,7 +4,7 @@ import { resolveGatewaySystemdServiceName, } from "../../daemon/constants.js"; import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js"; -import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; +import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js"; import { isSystemdUnavailableDetail, renderSystemdUnavailableHints, @@ -288,20 +288,23 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) defaultRuntime.error( errorText(`Gateway port ${status.port.port} is not listening (service appears running).`), ); + const serviceEnv = { ...process.env, ...service.command?.environment }; if (status.lastError) { defaultRuntime.error(`${errorText("Last gateway error:")} ${status.lastError}`); } if (process.platform === "linux") { - const env = service.command?.environment ?? process.env; - const unit = resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE); + const unit = resolveGatewaySystemdServiceName(serviceEnv.OPENCLAW_PROFILE); defaultRuntime.error( errorText(`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`), ); } else if (process.platform === "darwin") { - const logs = resolveGatewayLogPaths(service.command?.environment ?? process.env); + const logs = resolveGatewayLogPaths(serviceEnv); defaultRuntime.error(`${errorText("Logs:")} ${shortenHomePath(logs.stdoutPath)}`); defaultRuntime.error(`${errorText("Errors:")} ${shortenHomePath(logs.stderrPath)}`); } + defaultRuntime.error( + `${errorText("Restart log:")} ${shortenHomePath(resolveGatewayRestartLogPath(serviceEnv))}`, + ); spacer(); } diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 7750b43efbe..3a4c0ddc035 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -14,6 +14,7 @@ import { import { formatConfigIssueLines } from "../../config/issue-format.js"; import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; +import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js"; import { @@ -715,6 +716,9 @@ async function maybeRestartService(params: { for (const line of renderRestartDiagnostics(health)) { defaultRuntime.log(theme.muted(line)); } + defaultRuntime.log( + theme.muted(`Restart log: ${resolveGatewayRestartLogPath(process.env)}`), + ); defaultRuntime.log( theme.muted( `Run \`${replaceCliName(formatCliCommand("openclaw gateway status --deep"), CLI_NAME)}\` for details.`, diff --git a/src/commands/status-all/diagnosis.test.ts b/src/commands/status-all/diagnosis.test.ts index 9475d36ad2c..2c8d4a429e4 100644 --- a/src/commands/status-all/diagnosis.test.ts +++ b/src/commands/status-all/diagnosis.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { ProgressReporter } from "../../cli/progress.js"; -vi.mock("../../daemon/launchd.js", () => ({ +vi.mock("../../daemon/restart-logs.js", () => ({ resolveGatewayLogPaths: () => { throw new Error("skip log tail"); }, + resolveGatewayRestartLogPath: () => "/tmp/gateway-restart.log", })); vi.mock("./gateway.js", () => ({ diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 0c5a3318ac3..44f3b01c527 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -1,6 +1,6 @@ import type { ProgressReporter } from "../../cli/progress.js"; import { formatConfigIssueLine } from "../../config/issue-format.js"; -import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; +import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js"; import { formatPortDiagnostics, isDualStackLoopbackGatewayListeners, @@ -218,9 +218,11 @@ export async function appendStatusAllDiagnosis(params: { })(); if (logPaths) { params.progress.setLabel("Reading logs…"); - const [stderrTail, stdoutTail] = await Promise.all([ + const restartLogPath = resolveGatewayRestartLogPath(process.env); + const [stderrTail, stdoutTail, restartTail] = await Promise.all([ readFileTailLines(logPaths.stderrPath, 40).catch(() => []), readFileTailLines(logPaths.stdoutPath, 40).catch(() => []), + readFileTailLines(restartLogPath, 30).catch(() => []), ]); if (stderrTail.length > 0 || stdoutTail.length > 0) { lines.push(""); @@ -234,6 +236,13 @@ export async function appendStatusAllDiagnosis(params: { lines.push(` ${muted(line)}`); } } + if (restartTail.length > 0) { + lines.push(""); + lines.push(muted(`Gateway restart attempts (tail): ${restartLogPath}`)); + for (const line of summarizeLogTail(restartTail, { maxLines: 16 }).map(redactSecrets)) { + lines.push(` ${muted(line)}`); + } + } } params.progress.tick(); diff --git a/src/daemon/diagnostics.ts b/src/daemon/diagnostics.ts index ab897964698..03d1bdd0688 100644 --- a/src/daemon/diagnostics.ts +++ b/src/daemon/diagnostics.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import { resolveGatewayLogPaths } from "./launchd.js"; +import { resolveGatewayLogPaths } from "./restart-logs.js"; const GATEWAY_LOG_ERROR_PATTERNS = [ /refusing to bind gateway/i, diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 917525f46cf..1b6909b710f 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -20,7 +20,8 @@ import { scheduleDetachedLaunchdRestartHandoff, } from "./launchd-restart-handoff.js"; import { formatLine, toPosixPath, writeFormattedLines } from "./output.js"; -import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js"; +import { resolveHomeDir } from "./paths.js"; +import { resolveGatewayLogPaths } from "./restart-logs.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; import type { @@ -65,21 +66,6 @@ export function resolveLaunchAgentPlistPath(env: GatewayServiceEnv): string { return resolveLaunchAgentPlistPathForLabel(env, label); } -export function resolveGatewayLogPaths(env: GatewayServiceEnv): { - logDir: string; - stdoutPath: string; - stderrPath: string; -} { - const stateDir = resolveGatewayStateDir(env); - const logDir = path.join(stateDir, "logs"); - const prefix = env.OPENCLAW_LOG_PREFIX?.trim() || "gateway"; - return { - logDir, - stdoutPath: path.join(logDir, `${prefix}.log`), - stderrPath: path.join(logDir, `${prefix}.err.log`), - }; -} - export async function readLaunchAgentProgramArguments( env: GatewayServiceEnv, ): Promise { diff --git a/src/daemon/restart-logs.test.ts b/src/daemon/restart-logs.test.ts new file mode 100644 index 00000000000..bcfdb84308b --- /dev/null +++ b/src/daemon/restart-logs.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + GATEWAY_RESTART_LOG_FILENAME, + renderCmdRestartLogSetup, + renderPosixRestartLogSetup, + resolveGatewayLogPaths, + resolveGatewayRestartLogPath, +} from "./restart-logs.js"; + +describe("restart log conventions", () => { + it("resolves profile-aware gateway logs and restart attempts together", () => { + const env = { + HOME: "/Users/test", + OPENCLAW_PROFILE: "work", + }; + + expect(resolveGatewayLogPaths(env)).toEqual({ + logDir: "/Users/test/.openclaw-work/logs", + stdoutPath: "/Users/test/.openclaw-work/logs/gateway.log", + stderrPath: "/Users/test/.openclaw-work/logs/gateway.err.log", + }); + expect(resolveGatewayRestartLogPath(env)).toBe( + `/Users/test/.openclaw-work/logs/${GATEWAY_RESTART_LOG_FILENAME}`, + ); + }); + + it("honors OPENCLAW_STATE_DIR for restart attempts", () => { + const env = { + HOME: "/Users/test", + OPENCLAW_STATE_DIR: "/tmp/openclaw-state", + }; + + expect(resolveGatewayRestartLogPath(env)).toBe( + `/tmp/openclaw-state/logs/${GATEWAY_RESTART_LOG_FILENAME}`, + ); + }); + + it("renders best-effort POSIX log setup with escaped paths", () => { + const setup = renderPosixRestartLogSetup({ + HOME: "/Users/test's", + }); + + expect(setup).toContain("mkdir -p '/Users/test'\\''s/.openclaw/logs' 2>/dev/null || true"); + expect(setup).toContain( + "exec >>'/Users/test'\\''s/.openclaw/logs/gateway-restart.log' 2>&1 || true", + ); + }); + + it("renders CMD log setup with quoted paths", () => { + const setup = renderCmdRestartLogSetup({ + USERPROFILE: "C:\\Users\\Test User", + }); + + expect(setup.quotedLogPath).toBe('"C:\\Users\\Test User/.openclaw/logs/gateway-restart.log"'); + expect(setup.lines).toContain( + 'if not exist "C:\\Users\\Test User/.openclaw/logs" mkdir "C:\\Users\\Test User/.openclaw/logs" >nul 2>&1', + ); + }); +}); diff --git a/src/daemon/restart-logs.ts b/src/daemon/restart-logs.ts new file mode 100644 index 00000000000..3cac507b18f --- /dev/null +++ b/src/daemon/restart-logs.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { quoteCmdScriptArg } from "./cmd-argv.js"; +import { resolveGatewayStateDir } from "./paths.js"; +import type { GatewayServiceEnv } from "./service-types.js"; + +export const GATEWAY_RESTART_LOG_FILENAME = "gateway-restart.log"; + +export function resolveGatewayLogPaths(env: GatewayServiceEnv): { + logDir: string; + stdoutPath: string; + stderrPath: string; +} { + const stateDir = resolveGatewayStateDir(env); + const logDir = path.join(stateDir, "logs"); + const prefix = env.OPENCLAW_LOG_PREFIX?.trim() || "gateway"; + return { + logDir, + stdoutPath: path.join(logDir, `${prefix}.log`), + stderrPath: path.join(logDir, `${prefix}.err.log`), + }; +} + +export function resolveGatewayRestartLogPath(env: GatewayServiceEnv): string { + return path.join(resolveGatewayLogPaths(env).logDir, GATEWAY_RESTART_LOG_FILENAME); +} + +export function shellEscapeRestartLogValue(value: string): string { + return value.replace(/'/g, "'\\''"); +} + +export function renderPosixRestartLogSetup(env: GatewayServiceEnv): string { + const logDir = path.dirname(resolveGatewayRestartLogPath(env)); + const logPath = resolveGatewayRestartLogPath(env); + return `mkdir -p '${shellEscapeRestartLogValue(logDir)}' 2>/dev/null || true +exec >>'${shellEscapeRestartLogValue(logPath)}' 2>&1 || true`; +} + +export function renderCmdRestartLogSetup(env: GatewayServiceEnv): { + lines: string[]; + quotedLogPath: string; +} { + const logPath = resolveGatewayRestartLogPath(env); + const logDir = path.dirname(logPath); + const quotedLogDir = quoteCmdScriptArg(logDir); + const quotedLogPath = quoteCmdScriptArg(logPath); + return { + quotedLogPath, + lines: [ + `if not exist ${quotedLogDir} mkdir ${quotedLogDir} >nul 2>&1`, + `>> ${quotedLogPath} 2>&1 echo [%DATE% %TIME%] openclaw restart log initialized`, + ], + }; +} diff --git a/src/daemon/runtime-hints.test.ts b/src/daemon/runtime-hints.test.ts index 725edc48dfe..5fe27678ef6 100644 --- a/src/daemon/runtime-hints.test.ts +++ b/src/daemon/runtime-hints.test.ts @@ -16,6 +16,7 @@ describe("buildPlatformRuntimeLogHints", () => { ).toEqual([ "Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log", "Launchd stderr (if installed): /tmp/openclaw-state/logs/gateway.err.log", + "Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log", ]); }); @@ -23,17 +24,29 @@ describe("buildPlatformRuntimeLogHints", () => { expect( buildPlatformRuntimeLogHints({ platform: "linux", + env: { + OPENCLAW_STATE_DIR: "/tmp/openclaw-state", + }, systemdServiceName: "openclaw-gateway", windowsTaskName: "OpenClaw Gateway", }), - ).toEqual(["Logs: journalctl --user -u openclaw-gateway.service -n 200 --no-pager"]); + ).toEqual([ + "Logs: journalctl --user -u openclaw-gateway.service -n 200 --no-pager", + "Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log", + ]); expect( buildPlatformRuntimeLogHints({ platform: "win32", + env: { + OPENCLAW_STATE_DIR: "/tmp/openclaw-state", + }, systemdServiceName: "openclaw-gateway", windowsTaskName: "OpenClaw Gateway", }), - ).toEqual(['Logs: schtasks /Query /TN "OpenClaw Gateway" /V /FO LIST']); + ).toEqual([ + 'Logs: schtasks /Query /TN "OpenClaw Gateway" /V /FO LIST', + "Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log", + ]); }); }); diff --git a/src/daemon/runtime-hints.ts b/src/daemon/runtime-hints.ts index 09d106af7ea..33750ad5211 100644 --- a/src/daemon/runtime-hints.ts +++ b/src/daemon/runtime-hints.ts @@ -1,5 +1,5 @@ -import { resolveGatewayLogPaths } from "./launchd.js"; import { toPosixPath } from "./output.js"; +import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "./restart-logs.js"; function toDarwinDisplayPath(value: string): string { return toPosixPath(value).replace(/^[A-Za-z]:/, ""); @@ -12,19 +12,26 @@ export function buildPlatformRuntimeLogHints(params: { windowsTaskName: string; }): string[] { const platform = params.platform ?? process.platform; - const env = params.env ?? process.env; + const env = { ...process.env, ...params.env }; if (platform === "darwin") { const logs = resolveGatewayLogPaths(env); return [ `Launchd stdout (if installed): ${toDarwinDisplayPath(logs.stdoutPath)}`, `Launchd stderr (if installed): ${toDarwinDisplayPath(logs.stderrPath)}`, + `Restart attempts: ${toDarwinDisplayPath(resolveGatewayRestartLogPath(env))}`, ]; } if (platform === "linux") { - return [`Logs: journalctl --user -u ${params.systemdServiceName}.service -n 200 --no-pager`]; + return [ + `Logs: journalctl --user -u ${params.systemdServiceName}.service -n 200 --no-pager`, + `Restart attempts: ${resolveGatewayRestartLogPath(env)}`, + ]; } if (platform === "win32") { - return [`Logs: schtasks /Query /TN "${params.windowsTaskName}" /V /FO LIST`]; + return [ + `Logs: schtasks /Query /TN "${params.windowsTaskName}" /V /FO LIST`, + `Restart attempts: ${resolveGatewayRestartLogPath(env)}`, + ]; } return []; } diff --git a/src/daemon/runtime-hints.windows-paths.test.ts b/src/daemon/runtime-hints.windows-paths.test.ts index 095a40768ce..b5a93797aef 100644 --- a/src/daemon/runtime-hints.windows-paths.test.ts +++ b/src/daemon/runtime-hints.windows-paths.test.ts @@ -1,12 +1,17 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; const resolveGatewayLogPathsMock = vi.fn(() => ({ + logDir: "C:\\tmp\\openclaw-state\\logs", stdoutPath: "C:\\tmp\\openclaw-state\\logs\\gateway.log", stderrPath: "C:\\tmp\\openclaw-state\\logs\\gateway.err.log", })); +const resolveGatewayRestartLogPathMock = vi.fn( + () => "C:\\tmp\\openclaw-state\\logs\\gateway-restart.log", +); -vi.mock("./launchd.js", () => ({ +vi.mock("./restart-logs.js", () => ({ resolveGatewayLogPaths: resolveGatewayLogPathsMock, + resolveGatewayRestartLogPath: resolveGatewayRestartLogPathMock, })); let buildPlatformRuntimeLogHints: typeof import("./runtime-hints.js").buildPlatformRuntimeLogHints; @@ -26,6 +31,7 @@ describe("buildPlatformRuntimeLogHints", () => { ).toEqual([ "Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log", "Launchd stderr (if installed): /tmp/openclaw-state/logs/gateway.err.log", + "Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log", ]); }); });