mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-20 05:31:30 +00:00
fix(cli): add local logs fallback
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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}`)) {
|
||||
|
||||
@@ -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
162
src/logging/log-tail.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user