From 52a1d2f80d2d6b523615bc00d482469862fd29ac Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Mon, 1 Sep 2025 19:48:15 +0300 Subject: [PATCH] feat: add real-time logging for tmux panes - Introduce `tmux_logger` app for categorized output - Implement pane logging via `tmux pipe-pane` - Add `log`, `logpath`, `logreset` options to panes - Update `Pane` struct with logging state and cleanup - Refactor `logger.new` to use `LoggerFactoryArgs` --- examples/core/logger/logger.vsh | 17 ++ .../simple_declarative_test.heroscript | 12 +- lib/core/base/session_logger.v | 2 +- lib/core/logger/factory.v | 10 +- lib/core/logger/readme.md | 2 +- lib/osal/tmux/bin/tmux_logger.v | 67 +++++++ lib/osal/tmux/play.v | 1 + lib/osal/tmux/tmux_pane.v | 179 +++++++++++++++++- lib/osal/tmux/tmux_window.v | 18 ++ 9 files changed, 301 insertions(+), 7 deletions(-) create mode 100755 examples/core/logger/logger.vsh create mode 100644 lib/osal/tmux/bin/tmux_logger.v diff --git a/examples/core/logger/logger.vsh b/examples/core/logger/logger.vsh new file mode 100755 index 00000000..9d79e51d --- /dev/null +++ b/examples/core/logger/logger.vsh @@ -0,0 +1,17 @@ +#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run + +import freeflowuniverse.herolib.core.logger + +mut l := logger.new(path: '/tmp/vlogs')! + +l.log( + cat: 'system' + log: 'System started successfully' + logtype: .stdout +)! + +l.log( + cat: 'system' + log: 'Failed to connect\nRetrying in 5 seconds...' + logtype: .error +)! diff --git a/examples/osal/tmux/heroscripts/simple_declarative_test.heroscript b/examples/osal/tmux/heroscripts/simple_declarative_test.heroscript index 8b1ff813..d5e45fde 100755 --- a/examples/osal/tmux/heroscripts/simple_declarative_test.heroscript +++ b/examples/osal/tmux/heroscripts/simple_declarative_test.heroscript @@ -16,18 +16,28 @@ name:"test|demo|1" label:"first" cmd:"echo First pane ready" + log:true + logpath:"/tmp/logs" + logreset:true !!tmux.pane_ensure name:"test|demo|2" label:"second" cmd:"echo Second pane ready" + log:true + logpath:"/tmp/logs" !!tmux.pane_ensure name:"test|demo|3" label:"third" cmd:"echo Third pane ready" + log:true + logpath:"/tmp/logs" !!tmux.pane_ensure name:"test|demo|4" label:"fourth" - cmd:"echo Fourth pane ready" \ No newline at end of file + cmd:"echo Fourth pane ready" + log:true + logpath:"/tmp/logs" + logreset:true \ No newline at end of file diff --git a/lib/core/base/session_logger.v b/lib/core/base/session_logger.v index 0bd53c53..2f200cd3 100644 --- a/lib/core/base/session_logger.v +++ b/lib/core/base/session_logger.v @@ -4,7 +4,7 @@ import freeflowuniverse.herolib.core.logger pub fn (mut session Session) logger() !logger.Logger { return session.logger_ or { - mut l2 := logger.new('${session.path()!.path}/logs')! + mut l2 := logger.new(path: '${session.path()!.path}/logs')! l2 } } diff --git a/lib/core/logger/factory.v b/lib/core/logger/factory.v index 7403465c..dc12ff3c 100644 --- a/lib/core/logger/factory.v +++ b/lib/core/logger/factory.v @@ -2,8 +2,14 @@ module logger import freeflowuniverse.herolib.core.pathlib -pub fn new(path string) !Logger { - mut p := pathlib.get_dir(path: path, create: true)! +// Logger Factory +pub struct LoggerFactoryArgs { +pub mut: + path string +} + +pub fn new(args LoggerFactoryArgs) !Logger { + mut p := pathlib.get_dir(path: args.path, create: true)! return Logger{ path: p lastlog_time: 0 diff --git a/lib/core/logger/readme.md b/lib/core/logger/readme.md index 9b1c50ed..cf5ce661 100644 --- a/lib/core/logger/readme.md +++ b/lib/core/logger/readme.md @@ -1,6 +1,6 @@ # Logger Module -A simple logging system that provides structured logging with search capabilities. +A simple logging system that provides structured logging with search capabilities. Logs are stored in hourly files with a consistent format that makes them both human-readable and machine-parseable. diff --git a/lib/osal/tmux/bin/tmux_logger.v b/lib/osal/tmux/bin/tmux_logger.v new file mode 100644 index 00000000..84fbf360 --- /dev/null +++ b/lib/osal/tmux/bin/tmux_logger.v @@ -0,0 +1,67 @@ +module main + +import os +import io +import freeflowuniverse.herolib.core.logger + +fn main() { + if os.args.len < 2 { + eprintln('Usage: tmux_logger [pane_id]') + exit(1) + } + + log_path := os.args[1] + + mut l := logger.new(path: log_path) or { + eprintln('Failed to create logger: ${err}') + exit(1) + } + + // Read from stdin line by line and log with categorization + mut reader := io.new_buffered_reader(reader: os.stdin()) + for { + line := reader.read_line() or { break } + if line.len == 0 { + continue + } + + // Detect output type and set appropriate category + category, logtype := categorize_output(line) + + l.log( + cat: category + log: line + logtype: logtype + ) or { + eprintln('Failed to log line: ${err}') + continue + } + } +} + +fn categorize_output(line string) (string, logger.LogType) { + line_lower := line.to_lower().trim_space() + + // Error patterns - use .error logtype + if line_lower.contains('error') || line_lower.contains('err:') || line_lower.contains('failed') + || line_lower.contains('exception') || line_lower.contains('panic') + || line_lower.starts_with('e ') || line_lower.contains('fatal') + || line_lower.contains('critical') { + return 'error', logger.LogType.error + } + + // Warning patterns - use .stdout logtype but warning category + if line_lower.contains('warning') || line_lower.contains('warn:') + || line_lower.contains('deprecated') { + return 'warning', logger.LogType.stdout + } + + // Info/debug patterns - use .stdout logtype + if line_lower.contains('info:') || line_lower.contains('debug:') + || line_lower.starts_with('info ') || line_lower.starts_with('debug ') { + return 'info', logger.LogType.stdout + } + + // Default to stdout category and logtype + return 'stdout', logger.LogType.stdout +} diff --git a/lib/osal/tmux/play.v b/lib/osal/tmux/play.v index 50d7189c..7a5f3a0c 100644 --- a/lib/osal/tmux/play.v +++ b/lib/osal/tmux/play.v @@ -3,6 +3,7 @@ module tmux import freeflowuniverse.herolib.core.playbook { PlayBook } import freeflowuniverse.herolib.core.texttools import freeflowuniverse.herolib.osal.core as osal +import freeflowuniverse.herolib.ui.console pub fn play(mut plbook PlayBook) ! { if !plbook.exists(filter: 'tmux.') { diff --git a/lib/osal/tmux/tmux_pane.v b/lib/osal/tmux/tmux_pane.v index b246312c..d597ceea 100644 --- a/lib/osal/tmux/tmux_pane.v +++ b/lib/osal/tmux/tmux_pane.v @@ -4,6 +4,7 @@ import freeflowuniverse.herolib.osal.core as osal import freeflowuniverse.herolib.data.ourtime import freeflowuniverse.herolib.ui.console import time +import os @[heap] struct Pane { @@ -16,6 +17,10 @@ pub mut: env map[string]string created_at time.Time last_output_offset int // for tracking new logs + // Logging fields + log_enabled bool // whether logging is enabled for this pane + log_path string // path where logs are stored + logger_pid int // process id of the logger process } pub fn (mut p Pane) stats() !ProcessStats { @@ -154,10 +159,17 @@ pub fn (mut p Pane) send_keys(keys string) ! { // Kill this specific pane with comprehensive process cleanup pub fn (mut p Pane) kill() ! { - // First, kill all processes running in this pane + // First, disable logging if enabled + if p.log_enabled { + p.logging_disable() or { + console.print_debug('Warning: Failed to disable logging for pane %${p.id}: ${err}') + } + } + + // Then, kill all processes running in this pane p.kill_processes()! - // Then kill the tmux pane itself + // Finally, kill the tmux pane itself cmd := 'tmux kill-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id}' osal.execute_silent(cmd) or { return error('Cannot kill pane %${p.id}: ${err}') } } @@ -291,3 +303,166 @@ pub fn (p Pane) get_height() !int { } return res.output.trim_space().int() } + +@[params] +pub struct PaneLoggingEnableArgs { +pub mut: + logpath string // custom log path, if empty uses default + logreset bool // whether to reset/clear existing logs +} + +// Enable logging for this pane +pub fn (mut p Pane) logging_enable(args PaneLoggingEnableArgs) ! { + if p.log_enabled { + return error('Logging is already enabled for pane %${p.id}') + } + + // Determine log path + mut log_path := args.logpath + if log_path == '' { + // Default path: /tmp/tmux_logs/session/window/pane_id + log_path = '/tmp/tmux_logs/${p.window.session.name}/${p.window.name}/pane_${p.id}' + } + + // Create log directory if it doesn't exist + osal.exec(cmd: 'mkdir -p "${log_path}"', stdout: false, name: 'tmux_create_log_dir') or { + return error("Can't create log directory ${log_path}: ${err}") + } + + // Reset logs if requested + if args.logreset { + osal.exec( + cmd: 'rm -f "${log_path}"/*.log' + stdout: false + name: 'tmux_reset_logs' + ignore_error: true + ) or {} + } + + // Get the path to tmux_logger binary - use a more reliable path resolution + // Find the herolib root by looking for the lib directory + mut herolib_root := os.getwd() + for { + if os.exists('${herolib_root}/lib/osal/tmux/bin/tmux_logger.v') { + break + } + parent := os.dir(herolib_root) + if parent == herolib_root { + return error('Could not find herolib root directory') + } + herolib_root = parent + } + + logger_binary := '${herolib_root}/lib/osal/tmux/bin/tmux_logger' + logger_source := '${herolib_root}/lib/osal/tmux/bin/tmux_logger.v' + + // Check if binary exists, if not try to compile it + if !os.exists(logger_binary) { + console.print_debug('Compiling tmux_logger binary...') + console.print_debug('Source: ${logger_source}') + console.print_debug('Binary: ${logger_binary}') + compile_cmd := 'v -enable-globals -o "${logger_binary}" "${logger_source}"' + osal.exec(cmd: compile_cmd, stdout: false, name: 'tmux_compile_logger') or { + return error("Can't compile tmux_logger: ${err}") + } + } + + // Use a completely different approach: direct tmux pipe-pane with a buffer-based logger + // This ensures ALL output is captured in real-time without missing anything + buffer_logger_script := "#!/bin/bash +PANE_TARGET=\"${p.window.session.name}:@${p.window.id}.%${p.id}\" +LOG_PATH=\"${log_path}\" +LOGGER_BINARY=\"${logger_binary}\" +BUFFER_FILE=\"/tmp/tmux_pane_${p.id}_buffer.txt\" + +# Create a named pipe for real-time logging +PIPE_FILE=\"/tmp/tmux_pane_${p.id}_pipe\" +mkfifo \"\$PIPE_FILE\" 2>/dev/null || true + +# Start the logger process that reads from the pipe +\"\$LOGGER_BINARY\" \"\$LOG_PATH\" \"${p.id}\" < \"\$PIPE_FILE\" & +LOGGER_PID=\$! + +# Function to cleanup on exit +cleanup() { + kill \$LOGGER_PID 2>/dev/null || true + rm -f \"\$PIPE_FILE\" \"\$BUFFER_FILE\" + exit 0 +} +trap cleanup EXIT INT TERM + +# Start tmux pipe-pane to send all output to our pipe +tmux pipe-pane -t \"\$PANE_TARGET\" \"cat >> \"\$PIPE_FILE\"\" + +# Keep the script running and monitor the pane +while true; do + # Check if pane still exists + if ! tmux list-panes -t \"\$PANE_TARGET\" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +cleanup +" // Write the buffer logger script + + script_path := '/tmp/tmux_buffer_logger_${p.id}.sh' + os.write_file(script_path, buffer_logger_script) or { + return error("Can't create buffer logger script: ${err}") + } + + // Make script executable + osal.exec(cmd: 'chmod +x "${script_path}"', stdout: false, name: 'make_script_executable') or { + return error("Can't make script executable: ${err}") + } + + // Start the buffer logger script in background + start_cmd := 'nohup "${script_path}" > /dev/null 2>&1 &' + console.print_debug('Starting pane logging with buffer logger: ${start_cmd}') + + osal.exec(cmd: start_cmd, stdout: false, name: 'tmux_start_buffer_logger') or { + return error("Can't start buffer logger for pane %${p.id}: ${err}") + } + + // Update pane state + p.log_enabled = true + p.log_path = log_path + // Note: tmux pipe-pane doesn't return a PID, we'll track it differently if needed + + console.print_debug('Logging enabled for pane %${p.id} -> ${log_path}') +} + +// Disable logging for this pane +pub fn (mut p Pane) logging_disable() ! { + if !p.log_enabled { + return error('Logging is not enabled for pane %${p.id}') + } + + // Stop pipe-pane (in case it's running) + cmd := 'tmux pipe-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id}' + osal.exec(cmd: cmd, stdout: false, name: 'tmux_stop_logging', ignore_error: true) or {} + + // Kill the buffer logger script process + script_path := '/tmp/tmux_buffer_logger_${p.id}.sh' + kill_cmd := 'pkill -f "${script_path}"' + osal.exec(cmd: kill_cmd, stdout: false, name: 'kill_buffer_logger_script', ignore_error: true) or {} + + // Clean up script and temp files + cleanup_cmd := 'rm -f "${script_path}" "/tmp/tmux_pane_${p.id}_buffer.txt" "/tmp/tmux_pane_${p.id}_pipe"' + osal.exec(cmd: cleanup_cmd, stdout: false, name: 'cleanup_logging_files', ignore_error: true) or {} + + // Update pane state + p.log_enabled = false + p.log_path = '' + p.logger_pid = 0 + + console.print_debug('Logging disabled for pane %${p.id}') +} + +// Get logging status for this pane +pub fn (p Pane) logging_status() string { + if p.log_enabled { + return 'enabled (${p.log_path})' + } + return 'disabled' +} diff --git a/lib/osal/tmux/tmux_window.v b/lib/osal/tmux/tmux_window.v index 37b3be87..81f39064 100644 --- a/lib/osal/tmux/tmux_window.v +++ b/lib/osal/tmux/tmux_window.v @@ -223,6 +223,10 @@ pub mut: cmd string // command to run in new pane horizontal bool // true for horizontal split, false for vertical env map[string]string // environment variables + // Logging parameters + log bool // enable logging for this pane + logreset bool // reset/clear existing logs when enabling + logpath string // custom log path, if empty uses default } // Split the active pane horizontally or vertically @@ -272,12 +276,26 @@ pub fn (mut w Window) pane_split(args PaneSplitArgs) !&Pane { env: args.env created_at: time.now() last_output_offset: 0 + // Initialize logging fields + log_enabled: false + log_path: '' + logger_pid: 0 } // Add to window's panes and rescan to get current state w.panes << &new_pane w.scan()! + // Enable logging if requested + if args.log { + new_pane.logging_enable( + logpath: args.logpath + logreset: args.logreset + ) or { + console.print_debug('Warning: Failed to enable logging for pane %${new_pane.id}: ${err}') + } + } + // Return the new pane reference return &new_pane }