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
This commit is contained in:
38
src/logging/index.ts
Normal file
38
src/logging/index.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
182
src/logging/writer.ts
Normal file
182
src/logging/writer.ts
Normal file
@@ -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<void> {
|
||||||
|
// 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<void>((resolve, reject) => {
|
||||||
|
this.stdoutStream!.once('open', () => resolve());
|
||||||
|
this.stdoutStream!.once('error', reject);
|
||||||
|
}),
|
||||||
|
new Promise<void>((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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((resolve) => {
|
||||||
|
stream.once('drain', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flushes and closes both file handles.
|
||||||
|
*/
|
||||||
|
async close(): Promise<void> {
|
||||||
|
const closePromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
if (this.stdoutStream) {
|
||||||
|
closePromises.push(
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
this.stdoutStream!.end(() => {
|
||||||
|
this.stdoutStream = null;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
this.stdoutStream!.once('error', reject);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.stderrStream) {
|
||||||
|
closePromises.push(
|
||||||
|
new Promise<void>((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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user