fix(cli): add local logs fallback

This commit is contained in:
Peter Steinberger
2026-04-04 08:16:58 +09:00
parent de2eaccfce
commit 306fe841f5
4 changed files with 278 additions and 155 deletions

View File

@@ -3,12 +3,31 @@ import { runRegisteredCli } from "../test-utils/command-runner.js";
import { formatLogTimestamp, registerLogsCli } from "./logs-cli.js";
const callGatewayFromCli = vi.fn();
const readConfiguredLogTail = vi.fn();
const buildGatewayConnectionDetails = vi.fn(() => ({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
message: "",
}));
vi.mock("../gateway/call.js", () => ({
buildGatewayConnectionDetails: (
...args: Parameters<typeof import("../gateway/call.js").buildGatewayConnectionDetails>
) => buildGatewayConnectionDetails(...args),
}));
vi.mock("../logging/log-tail.js", () => ({
readConfiguredLogTail: (
...args: Parameters<typeof import("../logging/log-tail.js").readConfiguredLogTail>
) => readConfiguredLogTail(...args),
}));
vi.mock("./gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
return {
...actual,
callGatewayFromCli: (...args: unknown[]) => callGatewayFromCli(...args),
callGatewayFromCli: (...args: Parameters<typeof actual.callGatewayFromCli>) =>
callGatewayFromCli(...args),
};
});
@@ -22,6 +41,8 @@ async function runLogsCli(argv: string[]) {
describe("logs cli", () => {
afterEach(() => {
callGatewayFromCli.mockClear();
readConfiguredLogTail.mockClear();
buildGatewayConnectionDetails.mockClear();
vi.restoreAllMocks();
});
@@ -103,6 +124,39 @@ describe("logs cli", () => {
expect(stderrWrites.join("")).toContain("output stdout closed");
});
it("falls back to the local log file on loopback pairing-required errors", async () => {
callGatewayFromCli.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required"));
readConfiguredLogTail.mockResolvedValueOnce({
file: "/tmp/openclaw.log",
cursor: 5,
size: 5,
lines: ["local fallback line"],
truncated: false,
reset: false,
});
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
await runLogsCli(["logs"]);
expect(readConfiguredLogTail).toHaveBeenCalledWith({
cursor: undefined,
limit: 200,
maxBytes: 250_000,
});
expect(stdoutWrites.join("")).toContain("local fallback line");
expect(stderrWrites.join("")).toContain("reading local log file instead");
});
describe("formatLogTimestamp", () => {
it("formats UTC timestamp in plain mode by default", () => {
const result = formatLogTimestamp("2025-01-01T12:00:00.000Z");

View File

@@ -1,5 +1,8 @@
import { setTimeout as delay } from "node:timers/promises";
import type { Command } from "commander";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { isLoopbackHost } from "../gateway/net.js";
import { readConfiguredLogTail } from "../logging/log-tail.js";
import { parseLogLine } from "../logging/parse-log-line.js";
import { formatTimestamp, isValidTimeZone } from "../logging/timestamps.js";
import { formatDocsLink } from "../terminal/links.js";
@@ -25,6 +28,7 @@ type LogsTailPayload = {
lines?: string[];
truncated?: boolean;
reset?: boolean;
localFallback?: boolean;
};
type LogsCliOptions = {
@@ -42,6 +46,8 @@ type LogsCliOptions = {
expectFinal?: boolean;
};
const LOCAL_FALLBACK_NOTICE = "Gateway pairing required; reading local log file instead.";
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
@@ -57,16 +63,52 @@ async function fetchLogs(
): 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 },
{ progress: showProgress },
);
if (!payload || typeof payload !== "object") {
throw new Error("Unexpected logs.tail response");
try {
const payload = await callGatewayFromCli(
"logs.tail",
opts,
{ cursor, limit, maxBytes },
{ progress: showProgress },
);
if (!payload || typeof payload !== "object") {
throw new Error("Unexpected logs.tail response");
}
return payload as LogsTailPayload;
} catch (error) {
if (!shouldUseLocalLogsFallback(opts, error)) {
throw error;
}
return {
...(await readConfiguredLogTail({ cursor, limit, maxBytes })),
localFallback: true,
};
}
}
function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function shouldUseLocalLogsFallback(opts: LogsCliOptions, error: unknown): boolean {
const message = normalizeErrorMessage(error).toLowerCase();
if (!message.includes("pairing required")) {
return false;
}
if (typeof opts.url === "string" && opts.url.trim().length > 0) {
return false;
}
const connection = buildGatewayConnectionDetails();
if (connection.urlSource !== "local loopback") {
return false;
}
try {
return isLoopbackHost(new URL(connection.url).hostname);
} catch {
return false;
}
return payload as LogsTailPayload;
}
export function formatLogTimestamp(
@@ -294,6 +336,11 @@ export function registerLogsCli(program: Command) {
}
}
} else {
if (first && payload.file && payload.localFallback === true) {
if (!errorLine(colorize(rich, theme.warn, LOCAL_FALLBACK_NOTICE))) {
return;
}
}
if (first && payload.file) {
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
if (!logLine(`${prefix} ${payload.file}`)) {

View File

@@ -1,7 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import { getResolvedLoggerSettings } from "../../logging.js";
import { clamp } from "../../utils.js";
import { readConfiguredLogTail } from "../../logging/log-tail.js";
import {
ErrorCodes,
errorShape,
@@ -10,140 +7,6 @@ import {
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
const DEFAULT_LIMIT = 500;
const DEFAULT_MAX_BYTES = 250_000;
const MAX_LIMIT = 5000;
const MAX_BYTES = 1_000_000;
const ROLLING_LOG_RE = /^openclaw-\d{4}-\d{2}-\d{2}\.log$/;
function isRollingLogFile(file: string): boolean {
return ROLLING_LOG_RE.test(path.basename(file));
}
async function resolveLogFile(file: string): Promise<string> {
const stat = await fs.stat(file).catch(() => null);
if (stat) {
return file;
}
if (!isRollingLogFile(file)) {
return file;
}
const dir = path.dirname(file);
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
if (!entries) {
return file;
}
const candidates = await Promise.all(
entries
.filter((entry) => entry.isFile() && ROLLING_LOG_RE.test(entry.name))
.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
const fileStat = await fs.stat(fullPath).catch(() => null);
return fileStat ? { path: fullPath, mtimeMs: fileStat.mtimeMs } : null;
}),
);
const sorted = candidates
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
.toSorted((a, b) => b.mtimeMs - a.mtimeMs);
return sorted[0]?.path ?? file;
}
async function readLogSlice(params: {
file: string;
cursor?: number;
limit: number;
maxBytes: number;
}) {
const stat = await fs.stat(params.file).catch(() => null);
if (!stat) {
return {
cursor: 0,
size: 0,
lines: [] as string[],
truncated: false,
reset: false,
};
}
const size = stat.size;
const maxBytes = clamp(params.maxBytes, 1, MAX_BYTES);
const limit = clamp(params.limit, 1, MAX_LIMIT);
let cursor =
typeof params.cursor === "number" && Number.isFinite(params.cursor)
? Math.max(0, Math.floor(params.cursor))
: undefined;
let reset = false;
let truncated = false;
let start = 0;
if (cursor != null) {
if (cursor > size) {
reset = true;
start = Math.max(0, size - maxBytes);
truncated = start > 0;
} else {
start = cursor;
if (size - start > maxBytes) {
reset = true;
truncated = true;
start = Math.max(0, size - maxBytes);
}
}
} else {
start = Math.max(0, size - maxBytes);
truncated = start > 0;
}
if (size === 0 || size <= start) {
return {
cursor: size,
size,
lines: [] as string[],
truncated,
reset,
};
}
const handle = await fs.open(params.file, "r");
try {
let prefix = "";
if (start > 0) {
const prefixBuf = Buffer.alloc(1);
const prefixRead = await handle.read(prefixBuf, 0, 1, start - 1);
prefix = prefixBuf.toString("utf8", 0, prefixRead.bytesRead);
}
const length = Math.max(0, size - start);
const buffer = Buffer.alloc(length);
const readResult = await handle.read(buffer, 0, length, start);
const text = buffer.toString("utf8", 0, readResult.bytesRead);
let lines = text.split("\n");
if (start > 0 && prefix !== "\n") {
lines = lines.slice(1);
}
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
if (lines.length > limit) {
lines = lines.slice(lines.length - limit);
}
cursor = size;
return {
cursor,
size,
lines,
truncated,
reset,
};
} finally {
await handle.close();
}
}
export const logsHandlers: GatewayRequestHandlers = {
"logs.tail": async ({ params, respond }) => {
if (!validateLogsTailParams(params)) {
@@ -159,16 +22,13 @@ export const logsHandlers: GatewayRequestHandlers = {
}
const p = params as { cursor?: number; limit?: number; maxBytes?: number };
const configuredFile = getResolvedLoggerSettings().file;
try {
const file = await resolveLogFile(configuredFile);
const result = await readLogSlice({
file,
const result = await readConfiguredLogTail({
cursor: p.cursor,
limit: p.limit ?? DEFAULT_LIMIT,
maxBytes: p.maxBytes ?? DEFAULT_MAX_BYTES,
limit: p.limit,
maxBytes: p.maxBytes,
});
respond(true, { file, ...result }, undefined);
respond(true, result, undefined);
} catch (err) {
respond(
false,

162
src/logging/log-tail.ts Normal file
View File

@@ -0,0 +1,162 @@
import fs from "node:fs/promises";
import path from "node:path";
import { getResolvedLoggerSettings } from "../logging.js";
import { clamp } from "../utils.js";
const DEFAULT_LIMIT = 500;
const DEFAULT_MAX_BYTES = 250_000;
const MAX_LIMIT = 5000;
const MAX_BYTES = 1_000_000;
const ROLLING_LOG_RE = /^openclaw-\d{4}-\d{2}-\d{2}\.log$/;
export type LogTailPayload = {
file: string;
cursor: number;
size: number;
lines: string[];
truncated: boolean;
reset: boolean;
};
function isRollingLogFile(file: string): boolean {
return ROLLING_LOG_RE.test(path.basename(file));
}
async function resolveLogFile(file: string): Promise<string> {
const stat = await fs.stat(file).catch(() => null);
if (stat) {
return file;
}
if (!isRollingLogFile(file)) {
return file;
}
const dir = path.dirname(file);
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
if (!entries) {
return file;
}
const candidates = await Promise.all(
entries
.filter((entry) => entry.isFile() && ROLLING_LOG_RE.test(entry.name))
.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
const fileStat = await fs.stat(fullPath).catch(() => null);
return fileStat ? { path: fullPath, mtimeMs: fileStat.mtimeMs } : null;
}),
);
const sorted = candidates
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
.toSorted((a, b) => b.mtimeMs - a.mtimeMs);
return sorted[0]?.path ?? file;
}
async function readLogSlice(params: {
file: string;
cursor?: number;
limit: number;
maxBytes: number;
}): Promise<Omit<LogTailPayload, "file">> {
const stat = await fs.stat(params.file).catch(() => null);
if (!stat) {
return {
cursor: 0,
size: 0,
lines: [],
truncated: false,
reset: false,
};
}
const size = stat.size;
const maxBytes = clamp(params.maxBytes, 1, MAX_BYTES);
const limit = clamp(params.limit, 1, MAX_LIMIT);
let cursor =
typeof params.cursor === "number" && Number.isFinite(params.cursor)
? Math.max(0, Math.floor(params.cursor))
: undefined;
let reset = false;
let truncated = false;
let start = 0;
if (cursor != null) {
if (cursor > size) {
reset = true;
start = Math.max(0, size - maxBytes);
truncated = start > 0;
} else {
start = cursor;
if (size - start > maxBytes) {
reset = true;
truncated = true;
start = Math.max(0, size - maxBytes);
}
}
} else {
start = Math.max(0, size - maxBytes);
truncated = start > 0;
}
if (size === 0 || size <= start) {
return {
cursor: size,
size,
lines: [],
truncated,
reset,
};
}
const handle = await fs.open(params.file, "r");
try {
let prefix = "";
if (start > 0) {
const prefixBuf = Buffer.alloc(1);
const prefixRead = await handle.read(prefixBuf, 0, 1, start - 1);
prefix = prefixBuf.toString("utf8", 0, prefixRead.bytesRead);
}
const length = Math.max(0, size - start);
const buffer = Buffer.alloc(length);
const readResult = await handle.read(buffer, 0, length, start);
const text = buffer.toString("utf8", 0, readResult.bytesRead);
let lines = text.split("\n");
if (start > 0 && prefix !== "\n") {
lines = lines.slice(1);
}
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
if (lines.length > limit) {
lines = lines.slice(lines.length - limit);
}
cursor = size;
return {
cursor,
size,
lines,
truncated,
reset,
};
} finally {
await handle.close();
}
}
export async function readConfiguredLogTail(params?: {
cursor?: number;
limit?: number;
maxBytes?: number;
}): Promise<LogTailPayload> {
const file = await resolveLogFile(getResolvedLoggerSettings().file);
const result = await readLogSlice({
file,
cursor: params?.cursor,
limit: params?.limit ?? DEFAULT_LIMIT,
maxBytes: params?.maxBytes ?? DEFAULT_MAX_BYTES,
});
return { file, ...result };
}