Read chat logs incrementally and drain retention (Refs #62)

This commit is contained in:
2026-10-08 17:28:49 -04:00
parent 0cc4498b53
commit c10f602e8e
18 changed files with 646 additions and 39 deletions
+44 -1
View File
@@ -1,7 +1,8 @@
import { open, stat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { resolve, sep } from 'node:path';
import { rootOfServer, gameOfServer } from '../config.js';
import { nativeTail } from '../backends/files.js';
import { nativeTail, nativeLogChunk } from '../backends/files.js';
// Whitelisted log files exposed to the UI, mapped to their path relative
// to each server's root directory. Adding a new supported log = one entry
@@ -48,6 +49,48 @@ export interface TailResult {
truncated: boolean; // true when we didn't read from the start of the file
}
export interface LogCursor {
fileId: string; offset: number; size: number; modified: string;
anchorLength: number; anchorHash: string;
}
export interface LogChunk {
contentBase64: string; size: number; mtime: string; truncated: boolean;
cursor: LogCursor; reset: boolean;
}
/** Bounded byte reads; an anchor before the offset detects copy-truncate and regrowth. */
export async function readLogChunk(path: string, cursor?: LogCursor): Promise<LogChunk | TailResult | null> {
const remote = await nativeLogChunk(path, cursor);
if (remote !== undefined) return remote;
let file;
try { file = await open(path, 'r'); }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
try {
const st = await file.stat();
if (!st.isFile()) return null;
const fileId = `${st.dev}:${st.ino}`, modified = `${st.mtimeMs}:${st.ctimeMs}`, size = st.size;
const mtime = st.mtime.toISOString();
if (cursor?.fileId === fileId && cursor.modified === modified && cursor.size === size && cursor.offset === size) {
return { contentBase64: '', size, mtime, truncated: cursor.offset > 0, cursor, reset: false };
}
const hash = (value: Buffer) => createHash('sha256').update(value).digest('hex');
let reset = !cursor || cursor.fileId !== fileId || cursor.offset > size;
if (cursor && !reset) {
const anchor = Buffer.alloc(cursor.anchorLength);
const read = await file.read(anchor, 0, anchor.length, cursor.offset - anchor.length);
reset = read.bytesRead !== anchor.length || hash(anchor) !== cursor.anchorHash;
}
const start = reset ? Math.max(0, size - READ_WINDOW_BYTES) : cursor!.offset;
const content = Buffer.alloc(Math.min(READ_WINDOW_BYTES, size - start));
const read = await file.read(content, 0, content.length, start);
const offset = start + read.bytesRead;
const anchor = Buffer.alloc(Math.min(256, offset));
await file.read(anchor, 0, anchor.length, offset - anchor.length);
return { contentBase64: content.subarray(0, read.bytesRead).toString('base64'), size, mtime, truncated: start > 0, reset,
cursor: { fileId, modified, size, offset, anchorLength: anchor.length, anchorHash: hash(anchor) } };
} finally { await file.close(); }
}
const DEFAULT_TAIL_LINES = 200;
const MAX_TAIL_LINES = 2000;
const READ_WINDOW_BYTES = 256 * 1024; // 256 KB — plenty for a few hundred lines