diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e5f147e..f0c083bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.clawd.bot - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl. - CLI: avoid duplicating --profile/--dev flags when formatting commands. +- CLI: make `clawdbot logs --follow` output stable, keep spinners, and surface closed pipes. (#1318) — thanks @sebslight. - Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander. - Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts new file mode 100644 index 000000000..f6d08f9dc --- /dev/null +++ b/src/cli/logs-cli.test.ts @@ -0,0 +1,85 @@ +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const callGatewayFromCli = vi.fn(); + +vi.mock("./gateway-rpc.js", async () => { + const actual = await vi.importActual("./gateway-rpc.js"); + return { + ...actual, + callGatewayFromCli: (...args: unknown[]) => callGatewayFromCli(...args), + }; +}); + +describe("logs cli", () => { + afterEach(() => { + callGatewayFromCli.mockReset(); + }); + + it("writes output directly to stdout/stderr", async () => { + callGatewayFromCli.mockResolvedValueOnce({ + file: "/tmp/clawdbot.log", + cursor: 1, + size: 123, + lines: ["raw line"], + truncated: true, + reset: true, + }); + + const stdoutWrites: string[] = []; + const stderrWrites: string[] = []; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdoutWrites.push(String(chunk)); + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }); + + const { registerLogsCli } = await import("./logs-cli.js"); + const program = new Command(); + program.exitOverride(); + registerLogsCli(program); + + await program.parseAsync(["logs"], { from: "user" }); + + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + + expect(stdoutWrites.join("")).toContain("Log file:"); + expect(stdoutWrites.join("")).toContain("raw line"); + expect(stderrWrites.join("")).toContain("Log tail truncated"); + expect(stderrWrites.join("")).toContain("Log cursor reset"); + }); + + it("warns when the output pipe closes", async () => { + callGatewayFromCli.mockResolvedValueOnce({ + file: "/tmp/clawdbot.log", + lines: ["line one"], + }); + + const stderrWrites: string[] = []; + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => { + const err = new Error("EPIPE") as NodeJS.ErrnoException; + err.code = "EPIPE"; + throw err; + }); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }); + + const { registerLogsCli } = await import("./logs-cli.js"); + const program = new Command(); + program.exitOverride(); + registerLogsCli(program); + + await program.parseAsync(["logs"], { from: "user" }); + + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + + expect(stderrWrites.join("")).toContain("output stdout closed"); + }); +}); diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index c97f07961..2dbc544f8 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -1,13 +1,12 @@ import { setTimeout as delay } from "node:timers/promises"; import type { Command } from "commander"; -import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { parseLogLine } from "../logging/parse-log-line.js"; import { formatDocsLink } from "../terminal/links.js"; import { clearActiveProgressLine } from "../terminal/progress-line.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { formatCliCommand } from "./command-format.js"; -import { addGatewayClientOptions } from "./gateway-rpc.js"; +import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; type LogsTailPayload = { file?: string; @@ -44,15 +43,10 @@ async function fetchLogs( ): Promise { const limit = parsePositiveInt(opts.limit, 200); const maxBytes = parsePositiveInt(opts.maxBytes, 250_000); - const payload = await callGateway({ - url: opts.url, - token: opts.token, - method: "logs.tail", - params: { cursor, limit, maxBytes }, - expectFinal: Boolean(opts.expectFinal), - timeoutMs: Number(opts.timeout ?? 10_000), - clientName: GATEWAY_CLIENT_NAMES.CLI, - mode: GATEWAY_CLIENT_MODES.CLI, + const payload = await callGatewayFromCli("logs.tail", opts, { + cursor, + limit, + maxBytes, }); if (!payload || typeof payload !== "object") { throw new Error("Unexpected logs.tail response"); @@ -110,25 +104,68 @@ function formatLogLine( return [head, messageValue].filter(Boolean).join(" ").trim(); } -function writeLine(text: string, stream: NodeJS.WriteStream) { +let outputClosed = false; +let outputClosedNotice = false; + +function isBrokenPipeError(err: unknown): err is NodeJS.ErrnoException { + const code = (err as NodeJS.ErrnoException)?.code; + return code === "EPIPE" || code === "EIO"; +} + +function noteOutputClosed(err: NodeJS.ErrnoException, stream: NodeJS.WriteStream) { + if (outputClosedNotice) return; + outputClosedNotice = true; + const code = err.code ?? "EPIPE"; + const target = stream === process.stdout ? "stdout" : "stderr"; + const message = `clawdbot logs: output ${target} closed (${code}). Stopping tail.`; + try { + clearActiveProgressLine(); + process.stderr.write(`${message}\n`); + } catch { + // ignore secondary failures while reporting the broken pipe + } +} + +function handleWriteError(err: unknown, stream: NodeJS.WriteStream): boolean { + if (!isBrokenPipeError(err)) { + throw err; + } + outputClosed = true; + noteOutputClosed(err, stream); + return false; +} + +function writeText(text: string, stream: NodeJS.WriteStream): boolean { + if (outputClosed) return false; + try { + clearActiveProgressLine(); + } catch (err) { + if (!handleWriteError(err, process.stderr)) return false; + } + try { + stream.write(text); + return true; + } catch (err) { + return handleWriteError(err, stream); + } +} + +function writeLine(text: string, stream: NodeJS.WriteStream): boolean { // Avoid feeding CLI output back into the log file via console capture. - clearActiveProgressLine(); - stream.write(`${text}\n`); + return writeText(`${text}\n`, stream); } -function logLine(text: string) { - writeLine(text, process.stdout); +function logLine(text: string): boolean { + return writeLine(text, process.stdout); } -function errorLine(text: string) { - writeLine(text, process.stderr); +function errorLine(text: string): boolean { + return writeLine(text, process.stderr); } -function emitJsonLine(payload: Record, toStdErr = false) { +function emitJsonLine(payload: Record, toStdErr = false): boolean { const text = `${JSON.stringify(payload)}\n`; - clearActiveProgressLine(); - if (toStdErr) process.stderr.write(text); - else process.stdout.write(text); + return writeText(text, toStdErr ? process.stderr : process.stdout); } function emitGatewayError( @@ -143,21 +180,25 @@ function emitGatewayError( const errorText = err instanceof Error ? err.message : String(err); if (mode === "json") { - emitJsonLine( - { - type: "error", - message, - error: errorText, - details, - hint, - }, - true, - ); + if ( + !emitJsonLine( + { + type: "error", + message, + error: errorText, + details, + hint, + }, + true, + ) + ) { + return; + } return; } - errorLine(colorize(rich, theme.error, message)); - errorLine(details.message); + if (!errorLine(colorize(rich, theme.error, message))) return; + if (!errorLine(details.message)) return; errorLine(colorize(rich, theme.muted, hint)); } @@ -180,6 +221,8 @@ export function registerLogsCli(program: Command) { addGatewayClientOptions(logs); logs.action(async (opts: LogsCliOptions) => { + outputClosed = false; + outputClosedNotice = false; const interval = parsePositiveInt(opts.interval, 1000); let cursor: number | undefined; let first = true; @@ -199,51 +242,77 @@ export function registerLogsCli(program: Command) { const lines = Array.isArray(payload.lines) ? payload.lines : []; if (jsonMode) { if (first) { - emitJsonLine({ - type: "meta", - file: payload.file, - cursor: payload.cursor, - size: payload.size, - }); + if ( + !emitJsonLine({ + type: "meta", + file: payload.file, + cursor: payload.cursor, + size: payload.size, + }) + ) { + return; + } } for (const line of lines) { const parsed = parseLogLine(line); if (parsed) { - emitJsonLine({ type: "log", ...parsed }); + if (!emitJsonLine({ type: "log", ...parsed })) { + return; + } } else { - emitJsonLine({ type: "raw", raw: line }); + if (!emitJsonLine({ type: "raw", raw: line })) { + return; + } } } if (payload.truncated) { - emitJsonLine({ - type: "notice", - message: "Log tail truncated (increase --max-bytes).", - }); + if ( + !emitJsonLine({ + type: "notice", + message: "Log tail truncated (increase --max-bytes).", + }) + ) { + return; + } } if (payload.reset) { - emitJsonLine({ - type: "notice", - message: "Log cursor reset (file rotated).", - }); + if ( + !emitJsonLine({ + type: "notice", + message: "Log cursor reset (file rotated).", + }) + ) { + return; + } } } else { if (first && payload.file) { const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:"; - logLine(`${prefix} ${payload.file}`); + if (!logLine(`${prefix} ${payload.file}`)) { + return; + } } for (const line of lines) { - logLine( - formatLogLine(line, { - pretty, - rich, - }), - ); + if ( + !logLine( + formatLogLine(line, { + pretty, + rich, + }), + ) + ) { + return; + } } if (payload.truncated) { - errorLine("Log tail truncated (increase --max-bytes)."); + if (!errorLine("Log tail truncated (increase --max-bytes).")) { + return; + } } if (payload.reset) { - errorLine("Log cursor reset (file rotated)."); + if (!errorLine("Log cursor reset (file rotated).")) { + return; + } } } cursor =