Skip to content

Commit 306fe84

Browse files
committed
fix(cli): add local logs fallback
1 parent de2eacc commit 306fe84

4 files changed

Lines changed: 278 additions & 155 deletions

File tree

src/cli/logs-cli.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,31 @@ import { runRegisteredCli } from "../test-utils/command-runner.js";
33
import { formatLogTimestamp, registerLogsCli } from "./logs-cli.js";
44

55
const callGatewayFromCli = vi.fn();
6+
const readConfiguredLogTail = vi.fn();
7+
const buildGatewayConnectionDetails = vi.fn(() => ({
8+
url: "ws://127.0.0.1:18789",
9+
urlSource: "local loopback",
10+
message: "",
11+
}));
12+
13+
vi.mock("../gateway/call.js", () => ({
14+
buildGatewayConnectionDetails: (
15+
...args: Parameters<typeof import("../gateway/call.js").buildGatewayConnectionDetails>
16+
) => buildGatewayConnectionDetails(...args),
17+
}));
18+
19+
vi.mock("../logging/log-tail.js", () => ({
20+
readConfiguredLogTail: (
21+
...args: Parameters<typeof import("../logging/log-tail.js").readConfiguredLogTail>
22+
) => readConfiguredLogTail(...args),
23+
}));
624

725
vi.mock("./gateway-rpc.js", async () => {
826
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
927
return {
1028
...actual,
11-
callGatewayFromCli: (...args: unknown[]) => callGatewayFromCli(...args),
29+
callGatewayFromCli: (...args: Parameters<typeof actual.callGatewayFromCli>) =>
30+
callGatewayFromCli(...args),
1231
};
1332
});
1433

@@ -22,6 +41,8 @@ async function runLogsCli(argv: string[]) {
2241
describe("logs cli", () => {
2342
afterEach(() => {
2443
callGatewayFromCli.mockClear();
44+
readConfiguredLogTail.mockClear();
45+
buildGatewayConnectionDetails.mockClear();
2546
vi.restoreAllMocks();
2647
});
2748

@@ -103,6 +124,39 @@ describe("logs cli", () => {
103124
expect(stderrWrites.join("")).toContain("output stdout closed");
104125
});
105126

127+
it("falls back to the local log file on loopback pairing-required errors", async () => {
128+
callGatewayFromCli.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required"));
129+
readConfiguredLogTail.mockResolvedValueOnce({
130+
file: "/tmp/openclaw.log",
131+
cursor: 5,
132+
size: 5,
133+
lines: ["local fallback line"],
134+
truncated: false,
135+
reset: false,
136+
});
137+
138+
const stdoutWrites: string[] = [];
139+
const stderrWrites: string[] = [];
140+
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
141+
stdoutWrites.push(String(chunk));
142+
return true;
143+
});
144+
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
145+
stderrWrites.push(String(chunk));
146+
return true;
147+
});
148+
149+
await runLogsCli(["logs"]);
150+
151+
expect(readConfiguredLogTail).toHaveBeenCalledWith({
152+
cursor: undefined,
153+
limit: 200,
154+
maxBytes: 250_000,
155+
});
156+
expect(stdoutWrites.join("")).toContain("local fallback line");
157+
expect(stderrWrites.join("")).toContain("reading local log file instead");
158+
});
159+
106160
describe("formatLogTimestamp", () => {
107161
it("formats UTC timestamp in plain mode by default", () => {
108162
const result = formatLogTimestamp("2025-01-01T12:00:00.000Z");

src/cli/logs-cli.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { setTimeout as delay } from "node:timers/promises";
22
import type { Command } from "commander";
3+
import { buildGatewayConnectionDetails } from "../gateway/call.js";
4+
import { isLoopbackHost } from "../gateway/net.js";
5+
import { readConfiguredLogTail } from "../logging/log-tail.js";
36
import { parseLogLine } from "../logging/parse-log-line.js";
47
import { formatTimestamp, isValidTimeZone } from "../logging/timestamps.js";
58
import { formatDocsLink } from "../terminal/links.js";
@@ -25,6 +28,7 @@ type LogsTailPayload = {
2528
lines?: string[];
2629
truncated?: boolean;
2730
reset?: boolean;
31+
localFallback?: boolean;
2832
};
2933

3034
type LogsCliOptions = {
@@ -42,6 +46,8 @@ type LogsCliOptions = {
4246
expectFinal?: boolean;
4347
};
4448

49+
const LOCAL_FALLBACK_NOTICE = "Gateway pairing required; reading local log file instead.";
50+
4551
function parsePositiveInt(value: string | undefined, fallback: number): number {
4652
if (!value) {
4753
return fallback;
@@ -57,16 +63,52 @@ async function fetchLogs(
5763
): Promise<LogsTailPayload> {
5864
const limit = parsePositiveInt(opts.limit, 200);
5965
const maxBytes = parsePositiveInt(opts.maxBytes, 250_000);
60-
const payload = await callGatewayFromCli(
61-
"logs.tail",
62-
opts,
63-
{ cursor, limit, maxBytes },
64-
{ progress: showProgress },
65-
);
66-
if (!payload || typeof payload !== "object") {
67-
throw new Error("Unexpected logs.tail response");
66+
try {
67+
const payload = await callGatewayFromCli(
68+
"logs.tail",
69+
opts,
70+
{ cursor, limit, maxBytes },
71+
{ progress: showProgress },
72+
);
73+
if (!payload || typeof payload !== "object") {
74+
throw new Error("Unexpected logs.tail response");
75+
}
76+
return payload as LogsTailPayload;
77+
} catch (error) {
78+
if (!shouldUseLocalLogsFallback(opts, error)) {
79+
throw error;
80+
}
81+
return {
82+
...(await readConfiguredLogTail({ cursor, limit, maxBytes })),
83+
localFallback: true,
84+
};
85+
}
86+
}
87+
88+
function normalizeErrorMessage(error: unknown): string {
89+
if (error instanceof Error) {
90+
return error.message;
91+
}
92+
return String(error);
93+
}
94+
95+
function shouldUseLocalLogsFallback(opts: LogsCliOptions, error: unknown): boolean {
96+
const message = normalizeErrorMessage(error).toLowerCase();
97+
if (!message.includes("pairing required")) {
98+
return false;
99+
}
100+
if (typeof opts.url === "string" && opts.url.trim().length > 0) {
101+
return false;
102+
}
103+
const connection = buildGatewayConnectionDetails();
104+
if (connection.urlSource !== "local loopback") {
105+
return false;
106+
}
107+
try {
108+
return isLoopbackHost(new URL(connection.url).hostname);
109+
} catch {
110+
return false;
68111
}
69-
return payload as LogsTailPayload;
70112
}
71113

72114
export function formatLogTimestamp(
@@ -294,6 +336,11 @@ export function registerLogsCli(program: Command) {
294336
}
295337
}
296338
} else {
339+
if (first && payload.file && payload.localFallback === true) {
340+
if (!errorLine(colorize(rich, theme.warn, LOCAL_FALLBACK_NOTICE))) {
341+
return;
342+
}
343+
}
297344
if (first && payload.file) {
298345
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
299346
if (!logLine(`${prefix} ${payload.file}`)) {

src/gateway/server-methods/logs.ts

Lines changed: 5 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import fs from "node:fs/promises";
2-
import path from "node:path";
3-
import { getResolvedLoggerSettings } from "../../logging.js";
4-
import { clamp } from "../../utils.js";
1+
import { readConfiguredLogTail } from "../../logging/log-tail.js";
52
import {
63
ErrorCodes,
74
errorShape,
@@ -10,140 +7,6 @@ import {
107
} from "../protocol/index.js";
118
import type { GatewayRequestHandlers } from "./types.js";
129

13-
const DEFAULT_LIMIT = 500;
14-
const DEFAULT_MAX_BYTES = 250_000;
15-
const MAX_LIMIT = 5000;
16-
const MAX_BYTES = 1_000_000;
17-
const ROLLING_LOG_RE = /^openclaw-\d{4}-\d{2}-\d{2}\.log$/;
18-
19-
function isRollingLogFile(file: string): boolean {
20-
return ROLLING_LOG_RE.test(path.basename(file));
21-
}
22-
23-
async function resolveLogFile(file: string): Promise<string> {
24-
const stat = await fs.stat(file).catch(() => null);
25-
if (stat) {
26-
return file;
27-
}
28-
if (!isRollingLogFile(file)) {
29-
return file;
30-
}
31-
32-
const dir = path.dirname(file);
33-
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
34-
if (!entries) {
35-
return file;
36-
}
37-
38-
const candidates = await Promise.all(
39-
entries
40-
.filter((entry) => entry.isFile() && ROLLING_LOG_RE.test(entry.name))
41-
.map(async (entry) => {
42-
const fullPath = path.join(dir, entry.name);
43-
const fileStat = await fs.stat(fullPath).catch(() => null);
44-
return fileStat ? { path: fullPath, mtimeMs: fileStat.mtimeMs } : null;
45-
}),
46-
);
47-
const sorted = candidates
48-
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
49-
.toSorted((a, b) => b.mtimeMs - a.mtimeMs);
50-
return sorted[0]?.path ?? file;
51-
}
52-
53-
async function readLogSlice(params: {
54-
file: string;
55-
cursor?: number;
56-
limit: number;
57-
maxBytes: number;
58-
}) {
59-
const stat = await fs.stat(params.file).catch(() => null);
60-
if (!stat) {
61-
return {
62-
cursor: 0,
63-
size: 0,
64-
lines: [] as string[],
65-
truncated: false,
66-
reset: false,
67-
};
68-
}
69-
70-
const size = stat.size;
71-
const maxBytes = clamp(params.maxBytes, 1, MAX_BYTES);
72-
const limit = clamp(params.limit, 1, MAX_LIMIT);
73-
let cursor =
74-
typeof params.cursor === "number" && Number.isFinite(params.cursor)
75-
? Math.max(0, Math.floor(params.cursor))
76-
: undefined;
77-
let reset = false;
78-
let truncated = false;
79-
let start = 0;
80-
81-
if (cursor != null) {
82-
if (cursor > size) {
83-
reset = true;
84-
start = Math.max(0, size - maxBytes);
85-
truncated = start > 0;
86-
} else {
87-
start = cursor;
88-
if (size - start > maxBytes) {
89-
reset = true;
90-
truncated = true;
91-
start = Math.max(0, size - maxBytes);
92-
}
93-
}
94-
} else {
95-
start = Math.max(0, size - maxBytes);
96-
truncated = start > 0;
97-
}
98-
99-
if (size === 0 || size <= start) {
100-
return {
101-
cursor: size,
102-
size,
103-
lines: [] as string[],
104-
truncated,
105-
reset,
106-
};
107-
}
108-
109-
const handle = await fs.open(params.file, "r");
110-
try {
111-
let prefix = "";
112-
if (start > 0) {
113-
const prefixBuf = Buffer.alloc(1);
114-
const prefixRead = await handle.read(prefixBuf, 0, 1, start - 1);
115-
prefix = prefixBuf.toString("utf8", 0, prefixRead.bytesRead);
116-
}
117-
118-
const length = Math.max(0, size - start);
119-
const buffer = Buffer.alloc(length);
120-
const readResult = await handle.read(buffer, 0, length, start);
121-
const text = buffer.toString("utf8", 0, readResult.bytesRead);
122-
let lines = text.split("\n");
123-
if (start > 0 && prefix !== "\n") {
124-
lines = lines.slice(1);
125-
}
126-
if (lines.length > 0 && lines[lines.length - 1] === "") {
127-
lines = lines.slice(0, -1);
128-
}
129-
if (lines.length > limit) {
130-
lines = lines.slice(lines.length - limit);
131-
}
132-
133-
cursor = size;
134-
135-
return {
136-
cursor,
137-
size,
138-
lines,
139-
truncated,
140-
reset,
141-
};
142-
} finally {
143-
await handle.close();
144-
}
145-
}
146-
14710
export const logsHandlers: GatewayRequestHandlers = {
14811
"logs.tail": async ({ params, respond }) => {
14912
if (!validateLogsTailParams(params)) {
@@ -159,16 +22,13 @@ export const logsHandlers: GatewayRequestHandlers = {
15922
}
16023

16124
const p = params as { cursor?: number; limit?: number; maxBytes?: number };
162-
const configuredFile = getResolvedLoggerSettings().file;
16325
try {
164-
const file = await resolveLogFile(configuredFile);
165-
const result = await readLogSlice({
166-
file,
26+
const result = await readConfiguredLogTail({
16727
cursor: p.cursor,
168-
limit: p.limit ?? DEFAULT_LIMIT,
169-
maxBytes: p.maxBytes ?? DEFAULT_MAX_BYTES,
28+
limit: p.limit,
29+
maxBytes: p.maxBytes,
17030
});
171-
respond(true, { file, ...result }, undefined);
31+
respond(true, result, undefined);
17232
} catch (err) {
17333
respond(
17434
false,

0 commit comments

Comments
 (0)