Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/log-viewer-perf-colors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@perstack/tui-components": patch
"@perstack/log": patch
"@perstack/filesystem-storage": patch
---

fix: optimize log viewer TUI performance and improve run status colors

- Remove continueToNextStep and resolveToolResults from tree event loading (~400MB → ~15MB)
- Parallelize run event loading with Promise.all
- Add header-based checkpoint filtering for run-specific queries (25x faster)
- Use getFirstEvent for job query extraction instead of loading all events
- Fix double statSync in checkpoint sort
- Normalize run status colors: primary for completed/suspending, red for errors, muted for incomplete
68 changes: 60 additions & 8 deletions packages/filesystem/src/checkpoint.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
import {
closeSync,
existsSync,
openSync,
readdirSync,
readFileSync,
readSync,
statSync,
} from "node:fs"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { type Checkpoint, checkpointSchema } from "@perstack/core"
Expand Down Expand Up @@ -37,22 +45,66 @@ export function getCheckpointsByJobId(jobId: string): Checkpoint[] {
return []
}
const files = readdirSync(checkpointDir).filter((file) => file.endsWith(".json"))
const checkpoints: Checkpoint[] = []
// Read mtime once during the initial scan to avoid double statSync calls during sort
const fileEntries: { file: string; mtime: number }[] = []
for (const file of files) {
try {
const mtime = statSync(path.resolve(checkpointDir, file)).mtimeMs
fileEntries.push({ file, mtime })
} catch {
fileEntries.push({ file, mtime: 0 })
}
}
fileEntries.sort((a, b) => a.mtime - b.mtime)
const checkpoints: Checkpoint[] = []
for (const { file } of fileEntries) {
try {
const content = readFileSync(path.resolve(checkpointDir, file), "utf-8")
checkpoints.push(checkpointSchema.parse(JSON.parse(content)))
} catch {
// Ignore invalid checkpoints
}
}
return checkpoints.sort((a, b) => {
return checkpoints
}

export function getCheckpointsByRunId(jobId: string, runId: string): Checkpoint[] {
const checkpointDir = getCheckpointDir(jobId)
if (!existsSync(checkpointDir)) {
return []
}
const files = readdirSync(checkpointDir).filter((file) => file.endsWith(".json"))
// Pre-filter by reading only the first 256 bytes to check runId before full parse
const runIdPattern = `"runId":"${runId}"`
const matchingFiles: { file: string; mtime: number }[] = []
const headerBuf = Buffer.alloc(256)
for (const file of files) {
const filePath = path.resolve(checkpointDir, file)
try {
const aStat = statSync(path.resolve(checkpointDir, `${a.id}.json`))
const bStat = statSync(path.resolve(checkpointDir, `${b.id}.json`))
return aStat.mtimeMs - bStat.mtimeMs
const fd = openSync(filePath, "r")
try {
const bytesRead = readSync(fd, headerBuf, 0, 256, 0)
const header = headerBuf.toString("utf-8", 0, bytesRead)
if (header.includes(runIdPattern)) {
const mtime = statSync(filePath).mtimeMs
matchingFiles.push({ file, mtime })
}
} finally {
closeSync(fd)
}
} catch {
return 0
// Ignore inaccessible files
}
})
}
matchingFiles.sort((a, b) => a.mtime - b.mtime)
const checkpoints: Checkpoint[] = []
for (const { file } of matchingFiles) {
try {
const content = readFileSync(path.resolve(checkpointDir, file), "utf-8")
checkpoints.push(checkpointSchema.parse(JSON.parse(content)))
} catch {
// Ignore invalid checkpoints
}
}
return checkpoints
}
47 changes: 47 additions & 0 deletions packages/filesystem/src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,53 @@ export function getEventContents(
return events
}

/**
* Get a single event by type from a run (returns the first match by timestamp).
* Much faster than loading all events when you only need one.
*/
export function getFirstEvent(
jobId: string,
runId: string,
typeFilter: string,
): RunEvent | undefined {
const runDir = getRunDir(jobId, runId)
if (!existsSync(runDir)) {
return undefined
}
const files = readdirSync(runDir)
.filter((file) => file.startsWith("event-") && file.includes(`-${typeFilter}.json`))
.sort()
if (files.length === 0) return undefined
try {
const content = readFileSync(path.resolve(runDir, files[0]), "utf-8")
return JSON.parse(content) as RunEvent
} catch {
return undefined
}
}

/**
* Get event stats from filenames only (no file content reading).
* Returns total event count and max step number.
*/
export function getEventStats(
jobId: string,
runId: string,
): { eventCount: number; maxStep: number } {
const runDir = getRunDir(jobId, runId)
if (!existsSync(runDir)) {
return { eventCount: 0, maxStep: 0 }
}
const eventFiles = readdirSync(runDir).filter((file) => file.startsWith("event-"))
let maxStep = 0
for (const file of eventFiles) {
const parts = file.split(".")[0].split("-")
const step = Number(parts[2])
if (step > maxStep) maxStep = step
}
return { eventCount: eventFiles.length, maxStep }
}

export function getRunIdsByJobId(jobId: string): string[] {
const runsDir = path.resolve(getJobDir(jobId), "runs")
if (!existsSync(runsDir)) {
Expand Down
9 changes: 8 additions & 1 deletion packages/filesystem/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ export {
getCheckpointDir,
getCheckpointPath,
getCheckpointsByJobId,
getCheckpointsByRunId,
} from "./checkpoint.js"
export { defaultStoreEvent, getEventContents, getRunIdsByJobId } from "./event.js"
export {
defaultStoreEvent,
getEventContents,
getEventStats,
getFirstEvent,
getRunIdsByJobId,
} from "./event.js"
export {
createInitialJob,
getAllJobs,
Expand Down
47 changes: 35 additions & 12 deletions packages/log/src/data-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
getAllJobs,
getAllRuns,
getCheckpointsByJobId,
getCheckpointsByRunId,
getEventContents,
getEventStats,
getFirstEvent,
getRunIdsByJobId,
retrieveJob,
} from "@perstack/filesystem-storage"
Expand All @@ -16,23 +19,32 @@ export interface LogDataFetcher {
getLatestJob(): Promise<Job | undefined>
getRuns(jobId: string): Promise<RunSetting[]>
getCheckpoints(jobId: string): Promise<Checkpoint[]>
getCheckpointsForRun(jobId: string, runId: string): Promise<Checkpoint[]>
getCheckpoint(jobId: string, checkpointId: string): Promise<Checkpoint>
getEvents(jobId: string, runId: string): Promise<RunEvent[]>
getAllEventsForJob(jobId: string): Promise<RunEvent[]>
getTreeEventsForJob(jobId: string): Promise<RunEvent[]>
getFirstEventForRun(
jobId: string,
runId: string,
typeFilter: string,
): Promise<RunEvent | undefined>
}

export interface StorageAdapter {
getAllJobs(): Promise<Job[]>
retrieveJob(jobId: string): Promise<Job | undefined>
getCheckpointsByJobId(jobId: string): Promise<Checkpoint[]>
getCheckpointsByRunId(jobId: string, runId: string): Promise<Checkpoint[]>
retrieveCheckpoint(jobId: string, checkpointId: string): Promise<Checkpoint>
getEventContents(
jobId: string,
runId: string,
maxStep?: number,
typeFilter?: Set<string>,
): Promise<RunEvent[]>
getFirstEvent(jobId: string, runId: string, typeFilter: string): Promise<RunEvent | undefined>
getEventStats(jobId: string, runId: string): { eventCount: number; maxStep: number }
getAllRuns(): Promise<RunSetting[]>
getRunIdsByJobId(jobId: string): string[]
getJobIds(): string[]
Expand Down Expand Up @@ -105,6 +117,10 @@ export function createLogDataFetcher(storage: StorageAdapter): LogDataFetcher {
return storage.getCheckpointsByJobId(jobId)
},

async getCheckpointsForRun(jobId: string, runId: string): Promise<Checkpoint[]> {
return storage.getCheckpointsByRunId(jobId, runId)
},

async getCheckpoint(jobId: string, checkpointId: string): Promise<Checkpoint> {
return storage.retrieveCheckpoint(jobId, checkpointId)
},
Expand All @@ -115,11 +131,10 @@ export function createLogDataFetcher(storage: StorageAdapter): LogDataFetcher {

async getAllEventsForJob(jobId: string): Promise<RunEvent[]> {
const runs = await this.getRuns(jobId)
const allEvents: RunEvent[] = []
for (const run of runs) {
const events = await storage.getEventContents(jobId, run.runId)
allEvents.push(...events)
}
const results = await Promise.all(
runs.map((run) => storage.getEventContents(jobId, run.runId)),
)
const allEvents = results.flat()
return allEvents.sort((a, b) => a.timestamp - b.timestamp)
},

Expand All @@ -132,18 +147,23 @@ export function createLogDataFetcher(storage: StorageAdapter): LogDataFetcher {
"completeRun",
"stopRunByError",
"retry",
"continueToNextStep",
"resolveToolResults",
])
// Use getRunIdsByJobId to discover ALL runs (including those without run-setting.json)
const runIds = storage.getRunIdsByJobId(jobId)
const allEvents: RunEvent[] = []
for (const runId of runIds) {
const events = await storage.getEventContents(jobId, runId, undefined, treeEventTypes)
allEvents.push(...events)
}
const results = await Promise.all(
runIds.map((runId) => storage.getEventContents(jobId, runId, undefined, treeEventTypes)),
)
const allEvents = results.flat()
return allEvents.sort((a, b) => a.timestamp - b.timestamp)
},

async getFirstEventForRun(
jobId: string,
runId: string,
typeFilter: string,
): Promise<RunEvent | undefined> {
return storage.getFirstEvent(jobId, runId, typeFilter)
},
}
}

Expand All @@ -165,10 +185,13 @@ export function createStorageAdapter(basePath: string): StorageAdapter {
getAllJobs: async () => getAllJobs(),
retrieveJob: async (jobId) => retrieveJob(jobId),
getCheckpointsByJobId: async (jobId) => getCheckpointsByJobId(jobId),
getCheckpointsByRunId: async (jobId, runId) => getCheckpointsByRunId(jobId, runId),
retrieveCheckpoint: async (jobId, checkpointId) =>
defaultRetrieveCheckpoint(jobId, checkpointId),
getEventContents: async (jobId, runId, maxStep, typeFilter) =>
getEventContents(jobId, runId, maxStep, typeFilter),
getFirstEvent: async (jobId, runId, typeFilter) => getFirstEvent(jobId, runId, typeFilter),
getEventStats: (jobId, runId) => getEventStats(jobId, runId),
getAllRuns: async () => getAllRuns(),
getRunIdsByJobId: (jobId) => getRunIdsByJobId(jobId),
getJobIds: () => {
Expand Down
9 changes: 3 additions & 6 deletions packages/tui-components/src/log-viewer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ async function extractJobQuery(fetcher: LogDataFetcher, job: Job): Promise<strin
const runs = await fetcher.getRuns(job.id)
if (runs.length === 0) return undefined
const firstRun = runs[0]
const events = await fetcher.getEvents(job.id, firstRun.runId)
const startRunEvent = events.find((e: RunEvent) => e.type === "startRun")
const startRunEvent = await fetcher.getFirstEventForRun(job.id, firstRun.runId, "startRun")
if (!startRunEvent) return undefined
return extractQueryFromStartRun(startRunEvent)
} catch {
Expand Down Expand Up @@ -80,8 +79,7 @@ export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerA
setLoading(false)
return
}
const checkpoints = await fetcher.getCheckpoints(initialJobId)
const runCheckpoints = checkpoints.filter((cp: Checkpoint) => cp.runId === initialRunId)
const runCheckpoints = await fetcher.getCheckpointsForRun(initialJobId, initialRunId)
const run: RunInfo = { jobId: initialJobId, runId: initialRunId }
setScreen({ type: "checkpointList", job, run, checkpoints: runCheckpoints })
setLoading(false)
Expand Down Expand Up @@ -162,8 +160,7 @@ export const LogViewerApp = ({ fetcher, initialJobId, initialRunId }: LogViewerA
async (job: Job, run: RunInfo) => {
setLoading(true)
try {
const checkpoints = await fetcher.getCheckpoints(job.id)
const runCheckpoints = checkpoints.filter((cp: Checkpoint) => cp.runId === run.runId)
const runCheckpoints = await fetcher.getCheckpointsForRun(job.id, run.runId)
setScreen({ type: "checkpointList", job, run, checkpoints: runCheckpoints })
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ function getUsageIcon(ratio: number): string {
}

function statusColor(status: string): string {
if (status === "completed") return colors.success
if (status === "running" || status === "proceeding" || status === "init") return colors.accent
return colors.destructive
if (status === "completed") return colors.primary
if (status === "error") return colors.destructive
if (status === "suspending") return colors.primary
return colors.muted
}

// --- Job ---
Expand Down
6 changes: 3 additions & 3 deletions packages/tui-components/src/log-viewer/screens/run-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ function statusIcon(status: string): string {
function statusColor(status: string): string | undefined {
switch (status) {
case "completed":
return colors.success
return colors.primary
case "error":
return colors.destructive
case "running":
return colors.accent
case "suspending":
return colors.primary
default:
return colors.muted
}
Expand Down
Loading