From fc410e212addceaf61a1b3ccd25c4b31405c27b0 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 13:13:37 +0100 Subject: [PATCH] feat(01-04): create per-process log writer - ProcessLogWriter class for stdout/stderr capture - Timestamps each line with [YYYY-MM-DD HH:mm:ss.SSS] format - Backpressure handling via drain events - getStdoutStream/getStderrStream for direct piping - Module index exports types, classes, and createLogger helper - createLogger convenience function for default config --- src/logging/index.ts | 38 +++++++++ src/logging/writer.ts | 182 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 src/logging/index.ts create mode 100644 src/logging/writer.ts diff --git a/src/logging/index.ts b/src/logging/index.ts new file mode 100644 index 0000000..8195298 --- /dev/null +++ b/src/logging/index.ts @@ -0,0 +1,38 @@ +/** + * Logging Module + * + * File-based logging infrastructure for per-process stdout/stderr capture. + */ + +// Types +export type { LogLevel, LogEntry, LogConfig, LogStream } from './types.js'; + +// Classes +export { LogManager } from './manager.js'; +export { ProcessLogWriter } from './writer.js'; + +// Convenience functions +import { LogManager } from './manager.js'; +import { ProcessLogWriter } from './writer.js'; + +/** + * Creates a new ProcessLogWriter with default configuration. + * + * Convenience function for common use case of creating a log writer + * for a specific process using default log directory (~/.cw/logs). + * + * @param processId - Unique identifier for the process + * @returns A new ProcessLogWriter instance (call open() before writing) + * + * @example + * ```typescript + * const writer = createLogger('agent-001'); + * await writer.open(); + * await writer.writeStdout('Hello from agent\n'); + * await writer.close(); + * ``` + */ +export function createLogger(processId: string): ProcessLogWriter { + const manager = new LogManager(); + return new ProcessLogWriter(processId, manager); +} diff --git a/src/logging/writer.ts b/src/logging/writer.ts new file mode 100644 index 0000000..5c76e44 --- /dev/null +++ b/src/logging/writer.ts @@ -0,0 +1,182 @@ +/** + * Process Log Writer + * + * Handles per-process stdout/stderr capture to individual log files. + */ + +import { createWriteStream, type WriteStream } from 'node:fs'; +import type { LogManager } from './manager.js'; + +/** + * Formats a timestamp for log output. + * Format: [YYYY-MM-DD HH:mm:ss.SSS] + */ +function formatTimestamp(date: Date): string { + const pad = (n: number, w = 2) => n.toString().padStart(w, '0'); + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + const seconds = pad(date.getSeconds()); + const ms = pad(date.getMilliseconds(), 3); + return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}]`; +} + +/** + * Writes stdout/stderr output to per-process log files. + * + * Each line of output is prefixed with a timestamp. + * Handles backpressure by exposing drain events on the underlying streams. + */ +export class ProcessLogWriter { + private readonly processId: string; + private readonly logManager: LogManager; + private stdoutStream: WriteStream | null = null; + private stderrStream: WriteStream | null = null; + + constructor(processId: string, logManager: LogManager) { + this.processId = processId; + this.logManager = logManager; + } + + /** + * Opens file handles for stdout and stderr log files. + * Creates the process log directory if it doesn't exist. + */ + async open(): Promise { + // Ensure the process directory exists + await this.logManager.ensureProcessDir(this.processId); + + // Open write streams in append mode + const stdoutPath = this.logManager.getLogPath(this.processId, 'stdout'); + const stderrPath = this.logManager.getLogPath(this.processId, 'stderr'); + + this.stdoutStream = createWriteStream(stdoutPath, { flags: 'a' }); + this.stderrStream = createWriteStream(stderrPath, { flags: 'a' }); + + // Wait for both streams to be ready + await Promise.all([ + new Promise((resolve, reject) => { + this.stdoutStream!.once('open', () => resolve()); + this.stdoutStream!.once('error', reject); + }), + new Promise((resolve, reject) => { + this.stderrStream!.once('open', () => resolve()); + this.stderrStream!.once('error', reject); + }), + ]); + } + + /** + * Writes data to the stdout log file with timestamps. + * @param data - String or Buffer to write + * @returns Promise that resolves when write is complete (including drain if needed) + */ + async writeStdout(data: string | Buffer): Promise { + if (!this.stdoutStream) { + throw new Error('Log writer not open. Call open() first.'); + } + return this.writeWithTimestamp(this.stdoutStream, data); + } + + /** + * Writes data to the stderr log file with timestamps. + * @param data - String or Buffer to write + * @returns Promise that resolves when write is complete (including drain if needed) + */ + async writeStderr(data: string | Buffer): Promise { + if (!this.stderrStream) { + throw new Error('Log writer not open. Call open() first.'); + } + return this.writeWithTimestamp(this.stderrStream, data); + } + + /** + * Writes data with timestamp prefix, handling backpressure. + */ + private async writeWithTimestamp( + stream: WriteStream, + data: string | Buffer + ): Promise { + const content = typeof data === 'string' ? data : data.toString('utf-8'); + const timestamp = formatTimestamp(new Date()); + + // Prefix each line with timestamp + const lines = content.split('\n'); + const timestampedLines = lines + .map((line, index) => { + // Don't add timestamp to empty trailing line from split + if (index === lines.length - 1 && line === '') { + return ''; + } + return `${timestamp} ${line}`; + }) + .join('\n'); + + // Write with backpressure handling + const canWrite = stream.write(timestampedLines); + if (!canWrite) { + // Wait for drain event before continuing + await new Promise((resolve) => { + stream.once('drain', resolve); + }); + } + } + + /** + * Flushes and closes both file handles. + */ + async close(): Promise { + const closePromises: Promise[] = []; + + if (this.stdoutStream) { + closePromises.push( + new Promise((resolve, reject) => { + this.stdoutStream!.end(() => { + this.stdoutStream = null; + resolve(); + }); + this.stdoutStream!.once('error', reject); + }) + ); + } + + if (this.stderrStream) { + closePromises.push( + new Promise((resolve, reject) => { + this.stderrStream!.end(() => { + this.stderrStream = null; + resolve(); + }); + this.stderrStream!.once('error', reject); + }) + ); + } + + await Promise.all(closePromises); + } + + /** + * Gets the stdout write stream for direct piping. + * @returns The stdout WriteStream or null if not open + */ + getStdoutStream(): WriteStream | null { + return this.stdoutStream; + } + + /** + * Gets the stderr write stream for direct piping. + * @returns The stderr WriteStream or null if not open + */ + getStderrStream(): WriteStream | null { + return this.stderrStream; + } + + /** + * Gets the process ID for this writer. + */ + getProcessId(): string { + return this.processId; + } +}