fix: rock-solid idle detection in respawn controller

- Add 300-char rolling window to catch working patterns split across PTY chunks
- Check completion message BEFORE working patterns (priority fix)
- Clear rolling window on completion message (transition point)
- Increase working pattern absence threshold from 3s to 8s
- Add Session.isWorking safety check before confirming idle
- Add 20+ more working patterns (Compiling, Building, Processing, etc.)
- Make AI idle checker prompt more conservative (err toward WORKING)
- Update documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-26 00:55:29 +01:00
co-authored by Claude Opus 4.5
parent 5b87a41aac
commit 9aa5973970
21 changed files with 2561 additions and 360 deletions
+426
View File
@@ -0,0 +1,426 @@
/**
* @fileoverview File Stream Manager - Manages tail -f processes for live log viewing
*
* This module spawns and manages `tail -f` processes for streaming live file
* content to the frontend. It handles:
* - Spawning tail processes with initial history
* - Streaming output via callbacks
* - Cleanup when streams are closed
* - Security validation (path within working directory)
*
* @module file-stream-manager
*/
import { spawn, ChildProcess } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { EventEmitter } from 'node:events';
// ========== Configuration Constants ==========
/**
* Default number of historical lines to show when opening a file.
*/
const DEFAULT_TAIL_LINES = 50;
/**
* Maximum file size to stream (100MB).
* Skip files larger than this to prevent memory issues.
*/
const MAX_FILE_SIZE = 100 * 1024 * 1024;
/**
* Maximum concurrent streams per session.
*/
const MAX_STREAMS_PER_SESSION = 5;
/**
* Inactivity timeout for streams (5 minutes).
* Streams with no data for this long will be auto-closed.
*/
const STREAM_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
// ========== Types ==========
/**
* Represents an active file stream.
*/
export interface FileStream {
/** Unique stream identifier */
id: string;
/** Session this stream belongs to */
sessionId: string;
/** Absolute path to the file being streamed */
filePath: string;
/** Tail process handle */
process: ChildProcess;
/** Timestamp when stream was created */
createdAt: number;
/** Timestamp of last data received */
lastDataAt: number;
/** Whether the stream is still active */
active: boolean;
/** Callback for sending data to client */
onData: (data: string) => void;
/** Callback for stream end */
onEnd: () => void;
/** Callback for errors */
onError: (error: string) => void;
}
/**
* Options for creating a file stream.
*/
export interface CreateStreamOptions {
/** Session ID requesting the stream */
sessionId: string;
/** Path to the file to stream */
filePath: string;
/** Working directory for path validation */
workingDir: string;
/** Number of historical lines to show (default: 50) */
lines?: number;
/** Callback for data */
onData: (data: string) => void;
/** Callback for stream end */
onEnd: () => void;
/** Callback for errors */
onError: (error: string) => void;
}
/**
* Result of creating a stream.
*/
export interface CreateStreamResult {
success: boolean;
streamId?: string;
error?: string;
}
// ========== FileStreamManager Class ==========
/**
* Manages file streaming via tail -f processes.
* Ensures security by validating paths and limiting concurrent streams.
*
* @example
* ```typescript
* const manager = new FileStreamManager();
*
* const result = await manager.createStream({
* sessionId: 'session-123',
* filePath: '/var/log/app.log',
* workingDir: '/var/log',
* onData: (data) => sseClient.write(data),
* onEnd: () => sseClient.close(),
* onError: (err) => console.error(err),
* });
*
* // Later, to stop:
* manager.closeStream(result.streamId);
* ```
*/
export class FileStreamManager extends EventEmitter {
private streams: Map<string, FileStream> = new Map();
private sessionStreamCounts: Map<string, number> = new Map();
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super();
// Start cleanup timer for inactive streams
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), 60 * 1000);
}
// ========== Public Methods ==========
/**
* Create a new file stream.
*
* @param options - Stream configuration
* @returns Result with stream ID on success, error on failure
*/
async createStream(options: CreateStreamOptions): Promise<CreateStreamResult> {
const { sessionId, filePath, workingDir, lines = DEFAULT_TAIL_LINES, onData, onEnd, onError } = options;
// Check concurrent stream limit for this session
const currentCount = this.sessionStreamCounts.get(sessionId) || 0;
if (currentCount >= MAX_STREAMS_PER_SESSION) {
return {
success: false,
error: `Maximum ${MAX_STREAMS_PER_SESSION} concurrent streams per session`,
};
}
// Resolve and validate path
const validationResult = this.validatePath(filePath, workingDir);
if (!validationResult.valid) {
return { success: false, error: validationResult.error };
}
const absolutePath = validationResult.absolutePath!;
// Check file exists and size
try {
const stats = statSync(absolutePath);
if (stats.size > MAX_FILE_SIZE) {
return {
success: false,
error: `File too large (${Math.round(stats.size / 1024 / 1024)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB limit)`,
};
}
} catch {
return { success: false, error: 'File not found or not accessible' };
}
// Generate stream ID
const streamId = `${sessionId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Spawn tail process
const tailProcess = spawn('tail', ['-f', '-n', String(lines), absolutePath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (!tailProcess.pid) {
return { success: false, error: 'Failed to spawn tail process' };
}
const stream: FileStream = {
id: streamId,
sessionId,
filePath: absolutePath,
process: tailProcess,
createdAt: Date.now(),
lastDataAt: Date.now(),
active: true,
onData,
onEnd,
onError,
};
// Handle stdout
tailProcess.stdout?.on('data', (data: Buffer) => {
if (stream.active) {
stream.lastDataAt = Date.now();
onData(data.toString());
}
});
// Handle stderr
tailProcess.stderr?.on('data', (data: Buffer) => {
if (stream.active) {
onError(data.toString());
}
});
// Handle process exit
tailProcess.on('exit', (_code) => {
if (stream.active) {
stream.active = false;
onEnd();
this.removeStream(streamId);
}
});
// Handle errors
tailProcess.on('error', (err) => {
if (stream.active) {
stream.active = false;
onError(err.message);
this.removeStream(streamId);
}
});
// Store stream
this.streams.set(streamId, stream);
this.sessionStreamCounts.set(sessionId, currentCount + 1);
return { success: true, streamId };
}
/**
* Close a file stream.
*
* @param streamId - ID of the stream to close
* @returns true if stream was closed, false if not found
*/
closeStream(streamId: string): boolean {
const stream = this.streams.get(streamId);
if (!stream) return false;
stream.active = false;
// Kill the tail process
try {
stream.process.kill('SIGTERM');
// Force kill after 1 second if still running
setTimeout(() => {
try {
stream.process.kill('SIGKILL');
} catch {
// Already dead
}
}, 1000);
} catch {
// Process may have already exited
}
stream.onEnd();
this.removeStream(streamId);
return true;
}
/**
* Close all streams for a session.
*
* @param sessionId - Session ID whose streams to close
* @returns Number of streams closed
*/
closeSessionStreams(sessionId: string): number {
let closed = 0;
for (const [streamId, stream] of this.streams) {
if (stream.sessionId === sessionId) {
this.closeStream(streamId);
closed++;
}
}
return closed;
}
/**
* Get all active streams for a session.
*
* @param sessionId - Session ID to query
* @returns Array of stream info
*/
getSessionStreams(sessionId: string): Array<{ id: string; filePath: string; createdAt: number }> {
const result: Array<{ id: string; filePath: string; createdAt: number }> = [];
for (const [, stream] of this.streams) {
if (stream.sessionId === sessionId && stream.active) {
result.push({
id: stream.id,
filePath: stream.filePath,
createdAt: stream.createdAt,
});
}
}
return result;
}
/**
* Get count of active streams.
*/
get activeStreamCount(): number {
return this.streams.size;
}
/**
* Clean up and destroy the manager.
*/
destroy(): void {
// Stop cleanup timer
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
// Close all streams
for (const streamId of this.streams.keys()) {
this.closeStream(streamId);
}
}
// ========== Private Methods ==========
/**
* Validate that a file path is safe to access.
* Must be within the working directory (no path traversal).
*/
private validatePath(
filePath: string,
workingDir: string
): { valid: boolean; absolutePath?: string; error?: string } {
// Expand ~ to home directory
let expandedPath = filePath;
if (expandedPath.startsWith('~')) {
expandedPath = expandedPath.replace(/^~/, homedir());
}
// Resolve to absolute path
const absolutePath = isAbsolute(expandedPath)
? resolve(expandedPath)
: resolve(workingDir, expandedPath);
// Normalize the working directory
const normalizedWorkingDir = resolve(workingDir);
// Check if the resolved path is within the working directory
// or common log directories
const allowedPaths = [
normalizedWorkingDir,
'/var/log',
'/tmp',
resolve(homedir(), '.local/share'),
resolve(homedir(), '.cache'),
resolve(homedir(), 'logs'),
];
const isAllowed = allowedPaths.some((allowed) => {
const rel = relative(allowed, absolutePath);
return rel && !rel.startsWith('..') && !isAbsolute(rel);
});
if (!isAllowed) {
return {
valid: false,
error: `Path must be within working directory or allowed log directories`,
};
}
// Check for path traversal attempts
if (absolutePath.includes('..')) {
return { valid: false, error: 'Path traversal not allowed' };
}
// Check file exists
if (!existsSync(absolutePath)) {
return { valid: false, error: 'File does not exist' };
}
return { valid: true, absolutePath };
}
/**
* Remove a stream from tracking.
*/
private removeStream(streamId: string): void {
const stream = this.streams.get(streamId);
if (stream) {
// Decrement session count
const count = this.sessionStreamCounts.get(stream.sessionId) || 0;
if (count <= 1) {
this.sessionStreamCounts.delete(stream.sessionId);
} else {
this.sessionStreamCounts.set(stream.sessionId, count - 1);
}
}
this.streams.delete(streamId);
}
/**
* Clean up streams that have been inactive for too long.
*/
private cleanupInactiveStreams(): void {
const now = Date.now();
for (const [streamId, stream] of this.streams) {
if (now - stream.lastDataAt > STREAM_INACTIVITY_TIMEOUT_MS) {
console.log(`[FileStreamManager] Closing inactive stream: ${streamId}`);
this.closeStream(streamId);
}
}
}
}
// Export singleton instance
export const fileStreamManager = new FileStreamManager();