diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index aaada68..cf5bdd0 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,5 +1,26 @@ # Progress +## Phase 152 (Session-preview cold-start optimization without UI changes) — Completed +- Scope: + - Reduce the overview cold-start stall caused by session preview history reads without changing page structure, copy, or data semantics. +- Changed files: + - `src/clients/openclaw-live-client.ts` + - `test/openclaw-live-client-history.test.ts` + - `docs/PROGRESS.md` +- Implementation: + - Replaced the per-session external `tail` subprocess path with an in-process reverse file reader that scans recent JSONL history directly from the end of the file. + - Kept the same `sessionsHistory` API shape and the same history normalization logic so execution chains, previews, and UI copy stay unchanged. + - Added regression coverage for large cached history files and files without a trailing newline. +- Verification: + - `npm run build` + - `npm test` + - `npm run smoke:ui` + - Local route timing after restart on `UI_PORT=4522`: + - `overview` first open: about `5.38s` -> about `0.84s` + - `usage-cost` first open: about `1.18s` -> about `0.95s` + - `projects-tasks` first open: about `0.38s` -> about `0.57s` + - repeated `overview` opens: about `0.82s`, `0.62s`, `1.13s`, `0.25s`, `0.21s` + ## Phase 151 (Route-latency optimization without UI changes) — Completed - Scope: - Reduce slow first-navigation and post-cache-expiry stalls without changing page structure, copy, or data surfaces. diff --git a/src/clients/openclaw-live-client.ts b/src/clients/openclaw-live-client.ts index b46a4c9..816cc3e 100644 --- a/src/clients/openclaw-live-client.ts +++ b/src/clients/openclaw-live-client.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readdir, readFile } from "node:fs/promises"; +import { open, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import type { @@ -58,6 +58,9 @@ const INACTIVE_SESSION_STATES = new Set([ "canceled", ]); const FALLBACK_ACTIVE_RECENCY_WINDOW_MS = 45 * 60 * 1000; +const SESSION_HISTORY_TAIL_MIN_LINES = 80; +const SESSION_HISTORY_TAIL_LINE_MULTIPLIER = 8; +const SESSION_HISTORY_TAIL_CHUNK_BYTES = 64 * 1024; /** * Live read client using official OpenClaw CLI JSON outputs. @@ -429,12 +432,10 @@ async function readSessionHistoryFile( sessionFile: string, limit: number, ): Promise { + const targetLineCount = Math.max(limit * SESSION_HISTORY_TAIL_LINE_MULTIPLIER, SESSION_HISTORY_TAIL_MIN_LINES); try { - const { stdout } = await execFileAsync("tail", ["-n", String(Math.max(limit * 8, 80)), sessionFile], { - timeout: 5_000, - maxBuffer: 512 * 1024, - }); - return normalizeSessionHistoryChunk(stdout, limit); + const raw = await readRecentSessionHistoryChunk(sessionFile, targetLineCount); + return normalizeSessionHistoryChunk(raw, limit); } catch { try { const raw = await readFile(sessionFile, "utf8"); @@ -445,6 +446,49 @@ async function readSessionHistoryFile( } } +async function readRecentSessionHistoryChunk(sessionFile: string, targetLineCount: number): Promise { + const handle = await open(sessionFile, "r"); + try { + const { size } = await handle.stat(); + if (size <= 0) return ""; + + let position = size; + let newlineCount = 0; + const chunks: Buffer[] = []; + + while (position > 0 && newlineCount < targetLineCount) { + const bytesToRead = Math.min(SESSION_HISTORY_TAIL_CHUNK_BYTES, position); + position -= bytesToRead; + + const buffer = Buffer.allocUnsafe(bytesToRead); + const { bytesRead } = await handle.read(buffer, 0, bytesToRead, position); + if (bytesRead <= 0) break; + + const chunk = bytesRead === bytesToRead ? buffer : buffer.subarray(0, bytesRead); + chunks.push(chunk); + newlineCount += countLineFeeds(chunk); + } + + if (chunks.length === 0) return ""; + + const raw = Buffer.concat(chunks.reverse()).toString("utf8"); + if (position <= 0) return raw; + + const firstLineBreak = raw.indexOf("\n"); + return firstLineBreak >= 0 ? raw.slice(firstLineBreak + 1) : raw; + } finally { + await handle.close(); + } +} + +function countLineFeeds(buffer: Uint8Array): number { + let count = 0; + for (const byte of buffer) { + if (byte === 0x0a) count += 1; + } + return count; +} + function normalizeSessionHistoryChunk(raw: string, limit: number): SessionsHistoryResponse { const lines = raw .split(/\r?\n/) diff --git a/test/openclaw-live-client-history.test.ts b/test/openclaw-live-client-history.test.ts new file mode 100644 index 0000000..a36256b --- /dev/null +++ b/test/openclaw-live-client-history.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { OpenClawLiveClient } from "../src/clients/openclaw-live-client"; + +function attachSessionFile(client: OpenClawLiveClient, sessionKey: string, sessionFile: string): void { + const internalClient = client as OpenClawLiveClient & { + sessionCache: Map; + }; + internalClient.sessionCache.set(sessionKey, { sessionFile }); +} + +test("sessionsHistory reads recent history from large cached session files", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "openclaw-history-")); + try { + const sessionKey = "agent:main:cron:demo:run:child"; + const sessionFile = join(tempDir, "session.jsonl"); + const payload = "x".repeat(2048); + const lines = Array.from({ length: 120 }, (_, index) => + JSON.stringify({ seq: index + 1, message: `entry-${index + 1}`, payload }), + ); + await writeFile(sessionFile, `${lines.join("\n")}\n`, "utf8"); + + const client = new OpenClawLiveClient(); + attachSessionFile(client, sessionKey, sessionFile); + + const response = await client.sessionsHistory({ sessionKey, limit: 3 }); + const history = Array.isArray(response.json?.history) ? response.json.history : []; + + assert.deepEqual( + history.map((item) => (typeof item === "string" ? item : item.seq)), + [118, 119, 120], + ); + assert.match(response.rawText, /"seq":118/); + assert.match(response.rawText, /"seq":120/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("sessionsHistory keeps the last line when the history file has no trailing newline", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "openclaw-history-")); + try { + const sessionKey = "agent:coq:main"; + const sessionFile = join(tempDir, "session.jsonl"); + const lines = [ + JSON.stringify({ seq: 1, message: "first" }), + JSON.stringify({ seq: 2, message: "second" }), + JSON.stringify({ seq: 3, message: "third" }), + ]; + await writeFile(sessionFile, lines.join("\n"), "utf8"); + + const client = new OpenClawLiveClient(); + attachSessionFile(client, sessionKey, sessionFile); + + const response = await client.sessionsHistory({ sessionKey, limit: 2 }); + const history = Array.isArray(response.json?.history) ? response.json.history : []; + + assert.deepEqual( + history.map((item) => (typeof item === "string" ? item : item.seq)), + [2, 3], + ); + assert.equal(response.rawText.trim().split("\n").length, 2); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +});