File size: 4,243 Bytes
5710dd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
// apps/vis/server/src/lib/log-reader.ts
//
// Parse a kimi-code diagnostic log into structured lines for the Logs view.
//
// Lines look like:
//   2026-06-15T05:32:08.722Z INFO  llm config  turnStep=0.1 provider=openai …
// i.e. `<ISO time> <LEVEL> <message>  <key=value …>`. Anything that does not
// match (continuation lines, stack traces) is kept verbatim as a level-less,
// time-less message so nothing is dropped.

import { readdir, readFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';

import type { LogLine } from './agent-record-types';

/** Cap served lines so a multi-hundred-MB log cannot blow up the response.
 *  When exceeded we keep the TAIL (most recent), where failures usually are. */
const MAX_LINES = 20_000;

const LINE_RE = /^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s+([A-Za-z]+)\s+(.*)$/;
const FIELD_START_RE = /(^|\s)[A-Za-z_][\w.-]*=/;
const FIELD_RE = /([A-Za-z_][\w.-]*)=(\S+)/g;

export interface LogReadResult {
  lines: LogLine[];
  truncated: boolean;
}

/**
 * Discover a base log file plus its rotated siblings (`<base>`, `<base>.1`,
 * `<base>.2`, …) in chronological order, oldest first.
 *
 * The engine rotates by renaming the active file to `.1` and bumping older
 * archives to higher numbers (`_base/log/fileLog.ts` rotate()), so the
 * un-suffixed file is newest and `.N` is oldest. A bundle whose active log
 * has already rotated away may contain only `<base>.1`, etc. — which the
 * Logs tab must still find.
 */
export async function discoverLogFiles(baseLogPath: string): Promise<string[]> {
  const dir = dirname(baseLogPath);
  const base = basename(baseLogPath);
  let names: string[];
  try {
    names = (await readdir(dir, { withFileTypes: true }))
      .filter((e) => e.isFile())
      .map((e) => e.name);
  } catch {
    return [];
  }
  let hasActive = false;
  const rotated: { n: number; name: string }[] = [];
  const prefix = `${base}.`;
  for (const name of names) {
    if (name === base) {
      hasActive = true;
      continue;
    }
    if (name.startsWith(prefix)) {
      const suffix = name.slice(prefix.length);
      if (/^\d+$/.test(suffix)) rotated.push({ n: Number(suffix), name });
    }
  }
  rotated.sort((a, b) => b.n - a.n); // highest index == oldest → first
  const ordered = rotated.map((r) => join(dir, r.name));
  if (hasActive) ordered.push(join(dir, base)); // active is newest → last
  return ordered;
}

/**
 * Read and parse the given log files in order, concatenated into one structured
 * stream with continuous line numbers. Returns null when none could be read.
 * The MAX_LINES tail cap applies across the combined set.
 */
export async function readLogs(
  paths: readonly string[],
  maxLines = MAX_LINES,
): Promise<LogReadResult | null> {
  const allLines: string[] = [];
  let read = 0;
  for (const path of paths) {
    let raw: string;
    try {
      raw = await readFile(path, 'utf8');
    } catch {
      continue;
    }
    read += 1;
    const lines = raw.split(/\r?\n/);
    // Drop a single trailing empty line from each file's final newline.
    if (lines.length > 0 && lines.at(-1) === '') lines.pop();
    for (const line of lines) allLines.push(line);
  }
  if (read === 0) return null;

  const truncated = allLines.length > maxLines;
  const startLineNo = truncated ? allLines.length - maxLines : 0;
  const slice = truncated ? allLines.slice(startLineNo) : allLines;

  const lines: LogLine[] = slice.map((text, i) => parseLogLine(text, startLineNo + i + 1));
  return { lines, truncated };
}

export function parseLogLine(raw: string, lineNo: number): LogLine {
  const m = LINE_RE.exec(raw);
  if (m === null) {
    return { lineNo, time: null, level: null, message: raw, fields: {}, raw };
  }
  const time = m[1]!;
  const level = m[2]!.toUpperCase();
  const rest = m[3]!;

  const fields: Record<string, string> = {};
  let message = rest;
  const fieldStart = rest.search(FIELD_START_RE);
  if (fieldStart >= 0) {
    message = rest.slice(0, fieldStart).trim();
    const fieldsPart = rest.slice(fieldStart);
    for (const fm of fieldsPart.matchAll(FIELD_RE)) {
      fields[fm[1]!] = fm[2]!;
    }
  }
  return { lineNo, time, level, message: message.trim(), fields, raw };
}