Spaces:
Paused
Paused
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import type { GatewayRequestHandlers } from "./types.js"; | |
| import { getResolvedLoggerSettings } from "../../logging.js"; | |
| import { | |
| ErrorCodes, | |
| errorShape, | |
| formatValidationErrors, | |
| validateLogsTailParams, | |
| } from "../protocol/index.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 clamp(value: number, min: number, max: number) { | |
| return Math.max(min, Math.min(max, value)); | |
| } | |
| 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)) { | |
| respond( | |
| false, | |
| undefined, | |
| errorShape( | |
| ErrorCodes.INVALID_REQUEST, | |
| `invalid logs.tail params: ${formatValidationErrors(validateLogsTailParams.errors)}`, | |
| ), | |
| ); | |
| return; | |
| } | |
| 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, | |
| cursor: p.cursor, | |
| limit: p.limit ?? DEFAULT_LIMIT, | |
| maxBytes: p.maxBytes ?? DEFAULT_MAX_BYTES, | |
| }); | |
| respond(true, { file, ...result }, undefined); | |
| } catch (err) { | |
| respond( | |
| false, | |
| undefined, | |
| errorShape(ErrorCodes.UNAVAILABLE, `log read failed: ${String(err)}`), | |
| ); | |
| } | |
| }, | |
| }; | |