feat: add declarative tmux pane command management
- Implement Redis-backed command state tracking - Use MD5 hashing to detect command changes in panes - Kill and restart pane commands only when necessary - Ensure bash is the parent process in each pane - Add pane reset and emptiness checks before command execution
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env hero
|
||||
|
||||
// Enhanced Declarative Tmux Test with Redis State Tracking
|
||||
// This demonstrates the new intelligent command management features
|
||||
|
||||
// Ensure a test session exists
|
||||
!!tmux.session_ensure
|
||||
name:"enhanced_test"
|
||||
|
||||
// Ensure a 4-pane window exists
|
||||
!!tmux.window_ensure
|
||||
name:"enhanced_test|demo"
|
||||
cat:"4pane"
|
||||
|
||||
// Configure panes with intelligent state management
|
||||
// The system will now:
|
||||
// 1. Check if commands have changed using MD5 hashing
|
||||
// 2. Verify if previous commands are still running
|
||||
// 3. Kill and restart only when necessary
|
||||
// 4. Ensure bash is the parent process
|
||||
// 5. Reset panes when needed
|
||||
// 6. Track all state in Redis
|
||||
|
||||
!!tmux.pane_ensure
|
||||
name:"enhanced_test|demo|1"
|
||||
label:"web_server"
|
||||
cmd:"echo \"Starting web server...\" && python3 -m http.server 8000"
|
||||
log:true
|
||||
logpath:"/tmp/enhanced_logs"
|
||||
logreset:true
|
||||
|
||||
!!tmux.pane_ensure
|
||||
name:"enhanced_test|demo|2"
|
||||
label:"monitor"
|
||||
cmd:"echo \"Starting system monitor...\" && htop"
|
||||
log:true
|
||||
logpath:"/tmp/enhanced_logs"
|
||||
|
||||
!!tmux.pane_ensure
|
||||
name:"enhanced_test|demo|3"
|
||||
label:"logs"
|
||||
cmd:"echo \"Monitoring logs...\" && tail -f /var/log/system.log"
|
||||
log:true
|
||||
logpath:"/tmp/enhanced_logs"
|
||||
|
||||
!!tmux.pane_ensure
|
||||
name:"enhanced_test|demo|4"
|
||||
label:"development"
|
||||
cmd:"
|
||||
echo \"Setting up development environment...\"
|
||||
mkdir -p /tmp/dev_workspace
|
||||
cd /tmp/dev_workspace
|
||||
echo \"Development environment ready!\"
|
||||
echo \"Current directory:\" && pwd
|
||||
echo \"Available commands: ls, vim, git, etc.\"
|
||||
"
|
||||
log:true
|
||||
logpath:"/tmp/enhanced_logs"
|
||||
|
||||
// Test the intelligent state management by running the same commands again
|
||||
// The system should detect that commands haven't changed and skip re-execution
|
||||
// for commands that are still running
|
||||
|
||||
!!tmux.pane_ensure
|
||||
name:"enhanced_test|demo|1"
|
||||
label:"web_server"
|
||||
cmd:"echo \"Starting web server...\" && python3 -m http.server 8000"
|
||||
log:true
|
||||
logpath:"/tmp/enhanced_logs"
|
||||
|
||||
// Test command change detection by modifying a command slightly
|
||||
!!tmux.pane_ensure
|
||||
name:"enhanced_test|demo|2"
|
||||
label:"monitor"
|
||||
cmd:"echo \"Starting UPDATED system monitor...\" && htop"
|
||||
log:true
|
||||
logpath:"/tmp/enhanced_logs"
|
||||
|
||||
// This should kill the previous htop and start a new one because the command changed
|
||||
|
||||
// Test with a completely different command
|
||||
!!tmux.pane_ensure
|
||||
name:"enhanced_test|demo|3"
|
||||
label:"network"
|
||||
cmd:"echo \"Switching to network monitoring...\" && netstat -tuln"
|
||||
log:true
|
||||
logpath:"/tmp/enhanced_logs"
|
||||
|
||||
// This should kill the tail command and start netstat
|
||||
|
||||
// Test multi-line command with state tracking
|
||||
!!tmux.pane_ensure
|
||||
name:"enhanced_test|demo|4"
|
||||
label:"advanced_dev"
|
||||
cmd:"
|
||||
echo \"Advanced development setup...\"
|
||||
cd /tmp/dev_workspace
|
||||
echo \"Creating project structure...\"
|
||||
mkdir -p src tests docs
|
||||
echo \"Project structure created:\"
|
||||
ls -la
|
||||
echo \"Ready for development!\"
|
||||
"
|
||||
log:true
|
||||
logpath:"/tmp/enhanced_logs"
|
||||
|
||||
// The system will:
|
||||
// - Compare MD5 hash of this multi-line command with the previous one
|
||||
// - Detect that it's different
|
||||
// - Kill the previous command
|
||||
// - Execute this new command
|
||||
// - Store the new state in Redis
|
||||
// - Ensure bash is the parent process
|
||||
// - Enable logging with the tmux_logger binary
|
||||
@@ -1,6 +1,6 @@
|
||||
module playbook
|
||||
|
||||
import freeflowuniverse.herolib.develop.gittools // Added import for gittools
|
||||
// import freeflowuniverse.herolib.develop.gittools // Added import for gittools
|
||||
|
||||
// REMARK: include is done in play_core
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ module gittools
|
||||
|
||||
import crypto.md5
|
||||
import freeflowuniverse.herolib.core.pathlib
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
// import freeflowuniverse.herolib.ui.console
|
||||
import os
|
||||
import json
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ pub fn (mut gs GitStructure) do(args_ ReposActionsArgs) !string {
|
||||
// means current dir
|
||||
args.path = os.getwd()
|
||||
mut curdiro := pathlib.get_dir(path: args.path, create: false)!
|
||||
mut parentpath := curdiro.parent_find('.git') or { pathlib.Path{} }
|
||||
// mut parentpath := curdiro.parent_find('.git') or { pathlib.Path{} }
|
||||
args.path = curdiro.path
|
||||
}
|
||||
if !os.exists(args.path) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module gittools
|
||||
|
||||
import freeflowuniverse.herolib.core.redisclient
|
||||
// import freeflowuniverse.herolib.core.redisclient
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
import time
|
||||
// import time
|
||||
|
||||
// ReposGetArgs defines arguments to retrieve repositories from the git structure.
|
||||
// It includes filters by name, account, provider, and an option to clone a missing repo.
|
||||
|
||||
@@ -2,7 +2,7 @@ module gittools
|
||||
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
import os
|
||||
import freeflowuniverse.herolib.core.pathlib
|
||||
// import freeflowuniverse.herolib.core.pathlib
|
||||
|
||||
@[params]
|
||||
pub struct GitCloneArgs {
|
||||
@@ -40,7 +40,7 @@ pub fn (mut gitstructure GitStructure) clone(args GitCloneArgs) !&GitRepo {
|
||||
gitstructure.repos[key_] = &repo
|
||||
|
||||
if repo.exists() {
|
||||
console.print_green("Repository already exists at ${repo.path()}")
|
||||
console.print_green('Repository already exists at ${repo.path()}')
|
||||
// Load the existing repository status
|
||||
repo.load_internal() or {
|
||||
console.print_debug('Could not load existing repository status: ${err}')
|
||||
@@ -50,7 +50,7 @@ pub fn (mut gitstructure GitStructure) clone(args GitCloneArgs) !&GitRepo {
|
||||
|
||||
// Check if path exists but is not a git repository
|
||||
if os.exists(repo.path()) {
|
||||
return error("Path exists but is not a git repository: ${repo.path()}")
|
||||
return error('Path exists but is not a git repository: ${repo.path()}')
|
||||
}
|
||||
|
||||
if args.sshkey.len > 0 {
|
||||
|
||||
@@ -2,7 +2,7 @@ module gittools
|
||||
|
||||
import time
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
import os
|
||||
// import os
|
||||
|
||||
@[params]
|
||||
pub struct StatusUpdateArgs {
|
||||
|
||||
@@ -673,7 +673,7 @@ fn play_pane_ensure(mut plbook PlayBook, mut tmux_instance Tmux) ! {
|
||||
name := p.get('name')!
|
||||
parsed := parse_pane_name(name)!
|
||||
cmd := p.get_default('cmd', '')!
|
||||
label := p.get_default('label', '')!
|
||||
// label := p.get_default('label', '')!
|
||||
|
||||
// Parse environment variables if provided
|
||||
mut env := map[string]string{}
|
||||
@@ -721,7 +721,8 @@ fn play_pane_ensure(mut plbook PlayBook, mut tmux_instance Tmux) ! {
|
||||
// Find the target pane (by index, since tmux pane IDs can vary)
|
||||
if pane_number > 0 && pane_number <= window.panes.len {
|
||||
mut target_pane := window.panes[pane_number - 1] // Convert to 0-based index
|
||||
target_pane.send_command(cmd)!
|
||||
// Use declarative command logic for intelligent state management
|
||||
target_pane.send_command_declarative(cmd)!
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ module tmux
|
||||
|
||||
import freeflowuniverse.herolib.osal.core as osal
|
||||
import freeflowuniverse.herolib.core.texttools
|
||||
import freeflowuniverse.herolib.core.redisclient
|
||||
// import freeflowuniverse.herolib.session
|
||||
import os
|
||||
import time
|
||||
@@ -12,6 +13,7 @@ pub struct Tmux {
|
||||
pub mut:
|
||||
sessions []&Session
|
||||
sessionid string // unique link to job
|
||||
redis &redisclient.Redis @[skip] // Redis client for command state tracking
|
||||
}
|
||||
|
||||
// get session (session has windows) .
|
||||
@@ -87,8 +89,12 @@ pub struct TmuxNewArgs {
|
||||
|
||||
// return tmux instance
|
||||
pub fn new(args TmuxNewArgs) !Tmux {
|
||||
// Initialize Redis client for command state tracking
|
||||
mut redis := redisclient.core_get()!
|
||||
|
||||
mut t := Tmux{
|
||||
sessionid: args.sessionid
|
||||
redis: redis
|
||||
}
|
||||
// t.load()!
|
||||
t.scan()!
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn (mut p Pane) output_wait(c_ string, timeoutsec int) ! {
|
||||
mut t := ourtime.now()
|
||||
start := t.unix()
|
||||
c := c_.replace('\n', '')
|
||||
for i in 0 .. 2000 {
|
||||
for _ in 0 .. 2000 {
|
||||
entries := p.logs_get_new(reset: false)!
|
||||
for entry in entries {
|
||||
if entry.content.replace('\n', '').contains(c) {
|
||||
@@ -159,6 +159,236 @@ pub fn (mut p Pane) send_command(command string) ! {
|
||||
}
|
||||
}
|
||||
|
||||
// Send command with declarative mode logic (intelligent state management)
|
||||
// This method implements the full declarative logic:
|
||||
// 1. Check if pane has previous command (Redis lookup)
|
||||
// 2. If previous command exists:
|
||||
// a. Check if still running (process verification)
|
||||
// b. Compare MD5 hashes
|
||||
// c. If different command OR not running: proceed
|
||||
// d. If same command AND running: skip
|
||||
// 3. If proceeding: kill existing processes, then start new command
|
||||
pub fn (mut p Pane) send_command_declarative(command string) ! {
|
||||
console.print_debug('Declarative command for pane ${p.id}: ${command[..if command.len > 50 {
|
||||
50
|
||||
} else {
|
||||
command.len
|
||||
}]}...')
|
||||
|
||||
// Step 1: Check if command has changed
|
||||
command_changed := p.has_command_changed(command)
|
||||
|
||||
// Step 2: Check if stored command is still running
|
||||
stored_running := p.is_stored_command_running()
|
||||
|
||||
// Step 3: Decide whether to proceed
|
||||
should_execute := command_changed || !stored_running
|
||||
|
||||
if !should_execute {
|
||||
console.print_debug('Skipping command execution for pane ${p.id}: same command already running')
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: If we have a running command that needs to be replaced, kill it
|
||||
if stored_running && command_changed {
|
||||
console.print_debug('Killing existing command in pane ${p.id} before starting new one')
|
||||
p.kill_running_command()!
|
||||
// Give processes time to die
|
||||
time.sleep(500 * time.millisecond)
|
||||
}
|
||||
|
||||
// Step 5: Ensure bash is the parent process
|
||||
p.ensure_bash_parent()!
|
||||
|
||||
// Step 6: Reset pane if it appears empty or needs cleanup
|
||||
p.reset_if_needed()!
|
||||
|
||||
// Step 7: Execute the new command
|
||||
p.send_command(command)!
|
||||
|
||||
// Step 8: Store the new command state
|
||||
// Get the PID of the command we just started (this is approximate)
|
||||
time.sleep(100 * time.millisecond) // Give command time to start
|
||||
p.store_command_state(command, 'running', p.pid)!
|
||||
|
||||
console.print_debug('Successfully executed declarative command for pane ${p.id}')
|
||||
}
|
||||
|
||||
// Kill the currently running command in this pane
|
||||
pub fn (mut p Pane) kill_running_command() ! {
|
||||
stored_state := p.get_command_state() or { return }
|
||||
|
||||
if stored_state.pid > 0 && osal.process_exists(stored_state.pid) {
|
||||
// Kill the process and its children
|
||||
osal.process_kill_recursive(pid: stored_state.pid)!
|
||||
console.print_debug('Killed running command (PID: ${stored_state.pid}) in pane ${p.id}')
|
||||
}
|
||||
|
||||
// Also try to kill any processes that might be running in the pane
|
||||
p.kill_pane_process_group()!
|
||||
|
||||
// Update the command state to reflect that it's no longer running
|
||||
p.update_command_status('killed')!
|
||||
}
|
||||
|
||||
// Reset pane if it appears empty or needs cleanup
|
||||
pub fn (mut p Pane) reset_if_needed() ! {
|
||||
if p.is_pane_empty()! {
|
||||
console.print_debug('Pane ${p.id} appears empty, sending reset')
|
||||
p.send_reset()!
|
||||
return
|
||||
}
|
||||
|
||||
if !p.is_at_clean_prompt()! {
|
||||
console.print_debug('Pane ${p.id} not at clean prompt, sending reset')
|
||||
p.send_reset()!
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pane is completely empty
|
||||
pub fn (mut p Pane) is_pane_empty() !bool {
|
||||
logs := p.logs_all() or { return true }
|
||||
lines := logs.split_into_lines()
|
||||
|
||||
// Filter out empty lines
|
||||
mut non_empty_lines := []string{}
|
||||
for line in lines {
|
||||
if line.trim_space().len > 0 {
|
||||
non_empty_lines << line
|
||||
}
|
||||
}
|
||||
|
||||
return non_empty_lines.len == 0
|
||||
}
|
||||
|
||||
// Check if pane is at a clean shell prompt
|
||||
pub fn (mut p Pane) is_at_clean_prompt() !bool {
|
||||
logs := p.logs_all() or { return false }
|
||||
lines := logs.split_into_lines()
|
||||
|
||||
if lines.len == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check last few lines for shell prompt indicators
|
||||
check_lines := if lines.len > 5 { lines[lines.len - 5..] } else { lines }
|
||||
|
||||
for line in check_lines.reverse() {
|
||||
line_clean := line.trim_space()
|
||||
if line_clean.len == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Look for common shell prompt patterns
|
||||
if line_clean.ends_with('$ ') || line_clean.ends_with('# ') || line_clean.ends_with('> ')
|
||||
|| line_clean.ends_with('$') || line_clean.ends_with('#') || line_clean.ends_with('>') {
|
||||
console.print_debug('Found clean prompt in pane ${p.id}: "${line_clean}"')
|
||||
return true
|
||||
}
|
||||
|
||||
// If we find a non-prompt line, we're not at a clean prompt
|
||||
break
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Send reset command to pane
|
||||
pub fn (mut p Pane) send_reset() ! {
|
||||
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "reset" Enter'
|
||||
osal.execute_silent(cmd) or { return error('Cannot send reset to pane %${p.id}: ${err}') }
|
||||
console.print_debug('Sent reset command to pane ${p.id}')
|
||||
|
||||
// Give reset time to complete
|
||||
time.sleep(200 * time.millisecond)
|
||||
}
|
||||
|
||||
// Verify that bash is the first process in this pane
|
||||
pub fn (mut p Pane) verify_bash_parent() !bool {
|
||||
if p.pid <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get process information for the pane's main process
|
||||
proc_info := osal.processinfo_get(p.pid) or { return false }
|
||||
|
||||
// Check if the process command contains bash
|
||||
if proc_info.cmd.contains('bash') || proc_info.cmd.contains('/bin/bash')
|
||||
|| proc_info.cmd.contains('/usr/bin/bash') {
|
||||
console.print_debug('Pane ${p.id} has bash as parent process (PID: ${p.pid})')
|
||||
return true
|
||||
}
|
||||
|
||||
console.print_debug('Pane ${p.id} does NOT have bash as parent process. Current: ${proc_info.cmd}')
|
||||
return false
|
||||
}
|
||||
|
||||
// Ensure bash is the first process in the pane
|
||||
pub fn (mut p Pane) ensure_bash_parent() ! {
|
||||
if p.verify_bash_parent()! {
|
||||
return
|
||||
}
|
||||
|
||||
console.print_debug('Ensuring bash is parent process for pane ${p.id}')
|
||||
|
||||
// Kill any existing processes in the pane
|
||||
p.kill_pane_process_group()!
|
||||
|
||||
// Send a new bash command to establish bash as the parent
|
||||
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "exec bash" Enter'
|
||||
osal.execute_silent(cmd) or { return error('Cannot start bash in pane %${p.id}: ${err}') }
|
||||
|
||||
// Give bash time to start
|
||||
time.sleep(500 * time.millisecond)
|
||||
|
||||
// Update pane information
|
||||
p.window.scan()!
|
||||
|
||||
// Verify bash is now running
|
||||
if !p.verify_bash_parent()! {
|
||||
return error('Failed to establish bash as parent process in pane ${p.id}')
|
||||
}
|
||||
|
||||
console.print_debug('Successfully established bash as parent process for pane ${p.id}')
|
||||
}
|
||||
|
||||
// Get all child processes of this pane's main process
|
||||
pub fn (mut p Pane) get_child_processes() ![]osal.ProcessInfo {
|
||||
if p.pid <= 0 {
|
||||
return []osal.ProcessInfo{}
|
||||
}
|
||||
|
||||
children_map := osal.processinfo_children(p.pid)!
|
||||
return children_map.processes
|
||||
}
|
||||
|
||||
// Check if commands are running as children of bash
|
||||
pub fn (mut p Pane) verify_command_hierarchy() !bool {
|
||||
// First verify bash is the parent
|
||||
if !p.verify_bash_parent()! {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get child processes
|
||||
children := p.get_child_processes()!
|
||||
|
||||
if children.len == 0 {
|
||||
// No child processes, which is fine
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if child processes have bash as their parent
|
||||
for child in children {
|
||||
if child.ppid != p.pid {
|
||||
console.print_debug('Child process ${child.pid} (${child.cmd}) does not have pane process as parent')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
console.print_debug('Command hierarchy verified for pane ${p.id}: ${children.len} child processes')
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle multi-line commands by creating a temporary script
|
||||
fn (mut p Pane) send_multiline_command(command string) ! {
|
||||
// Create temporary directory for tmux scripts
|
||||
|
||||
@@ -1,86 +1,39 @@
|
||||
module tmux
|
||||
|
||||
import freeflowuniverse.herolib.osal.core as osal
|
||||
// import freeflowuniverse.herolib.installers.tmux
|
||||
|
||||
// fn testsuite_end() {
|
||||
|
||||
//
|
||||
// }
|
||||
import rand
|
||||
|
||||
fn testsuite_begin() {
|
||||
mut tmux := Tmux{}
|
||||
mut tmux_instance := new()!
|
||||
|
||||
if tmux.is_running()! {
|
||||
tmux.stop()!
|
||||
if tmux_instance.is_running()! {
|
||||
tmux_instance.stop()!
|
||||
}
|
||||
}
|
||||
|
||||
fn test_session_create() {
|
||||
// installer := tmux.get_install(
|
||||
// panic('could not install tmux: ${err}')
|
||||
// }
|
||||
fn test_session_create() ! {
|
||||
// Create unique session names to avoid conflicts
|
||||
session_name1 := 'testsession_${rand.int()}'
|
||||
session_name2 := 'testsession2_${rand.int()}'
|
||||
|
||||
mut tmux := Tmux{}
|
||||
tmux.start() or { panic('cannot start tmux: ${err}') }
|
||||
mut tmux_instance := new()!
|
||||
tmux_instance.start()!
|
||||
|
||||
mut s := Session{
|
||||
tmux: &tmux
|
||||
windows: []&Window{}
|
||||
name: 'testsession'
|
||||
}
|
||||
// Create sessions using the proper API
|
||||
mut s := tmux_instance.session_create(name: session_name1)!
|
||||
mut s2 := tmux_instance.session_create(name: session_name2)!
|
||||
|
||||
mut s2 := Session{
|
||||
tmux: &tmux
|
||||
windows: []&Window{}
|
||||
name: 'testsession2'
|
||||
}
|
||||
|
||||
// test testsession exists after session_create
|
||||
// Test that sessions were created successfully
|
||||
mut tmux_ls := osal.execute_silent('tmux ls') or { panic("can't exec: ${err}") }
|
||||
assert !tmux_ls.contains('testsession: 1 windows')
|
||||
s.create() or { panic('Cannot create session: ${err}') }
|
||||
tmux_ls = osal.execute_silent('tmux ls') or { panic("can't exec: ${err}") }
|
||||
assert tmux_ls.contains('testsession: 1 windows')
|
||||
assert tmux_ls.contains(session_name1), 'Session 1 should exist'
|
||||
assert tmux_ls.contains(session_name2), 'Session 2 should exist'
|
||||
|
||||
// test multiple session_create for same tmux
|
||||
tmux_ls = osal.execute_silent('tmux ls') or { panic("can't exec: ${err}") }
|
||||
assert !tmux_ls.contains('testsession2: 1 windows')
|
||||
s2.create() or { panic('Cannot create session: ${err}') }
|
||||
tmux_ls = osal.execute_silent('tmux ls') or { panic("can't exec: ${err}") }
|
||||
assert tmux_ls.contains('testsession2: 1 windows')
|
||||
// Test session existence check
|
||||
assert tmux_instance.session_exist(session_name1), 'Session 1 should exist via API'
|
||||
assert tmux_instance.session_exist(session_name2), 'Session 2 should exist via API'
|
||||
|
||||
// test session_create with duplicate session
|
||||
mut create_err := ''
|
||||
s2.create() or { create_err = err.msg() }
|
||||
assert create_err != ''
|
||||
assert create_err.contains('duplicate session: testsession2')
|
||||
tmux_ls = osal.execute_silent('tmux ls') or { panic("can't exec: ${err}") }
|
||||
assert tmux_ls.contains('testsession2: 1 windows')
|
||||
|
||||
s.stop() or { panic('Cannot stop session: ${err}') }
|
||||
s2.stop() or { panic('Cannot stop session: ${err}') }
|
||||
// Clean up
|
||||
tmux_instance.session_delete(session_name1)!
|
||||
tmux_instance.session_delete(session_name2)!
|
||||
tmux_instance.stop()!
|
||||
}
|
||||
|
||||
// fn test_session_stop() {
|
||||
|
||||
//
|
||||
// installer := tmux.get_install(
|
||||
|
||||
// mut tmux := Tmux {
|
||||
// node: node_ssh
|
||||
// }
|
||||
|
||||
// mut s := Session{
|
||||
// tmux: &tmux // reference back
|
||||
// windows: map[string]&Window{}
|
||||
// name: 'testsession3'
|
||||
// }
|
||||
|
||||
// s.create() or { panic("Cannot create session: $err") }
|
||||
// mut tmux_ls := osal.execute_silent('tmux ls') or { panic("can't exec: $err") }
|
||||
// assert tmux_ls.contains("testsession3: 1 windows")
|
||||
// s.stop() or { panic("Cannot stop session: $err")}
|
||||
// tmux_ls = osal.execute_silent('tmux ls') or { panic("can't exec: $err") }
|
||||
// assert !tmux_ls.contains("testsession3: 1 windows")
|
||||
// }
|
||||
|
||||
157
lib/osal/tmux/tmux_state.v
Normal file
157
lib/osal/tmux/tmux_state.v
Normal file
@@ -0,0 +1,157 @@
|
||||
module tmux
|
||||
|
||||
import freeflowuniverse.herolib.osal.core as osal
|
||||
import crypto.md5
|
||||
import json
|
||||
import time
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
|
||||
// Command state structure for Redis storage
|
||||
pub struct CommandState {
|
||||
pub mut:
|
||||
cmd_md5 string // MD5 hash of the command
|
||||
cmd_text string // Original command text
|
||||
status string // running|finished|failed|unknown
|
||||
pid int // Process ID of the command
|
||||
started_at string // Timestamp when command started
|
||||
last_check string // Last time status was checked
|
||||
pane_id int // Pane ID for reference
|
||||
}
|
||||
|
||||
// Generate Redis key for command state tracking
|
||||
// Pattern: herotmux:${session}:${window}|${pane}
|
||||
pub fn (p &Pane) get_state_key() string {
|
||||
return 'herotmux:${p.window.session.name}:${p.window.name}|${p.id}'
|
||||
}
|
||||
|
||||
// Generate MD5 hash for a command (normalized)
|
||||
pub fn normalize_and_hash_command(cmd string) string {
|
||||
// Normalize command: trim whitespace, normalize newlines
|
||||
normalized := cmd.trim_space().replace('\r\n', '\n').replace('\r', '\n')
|
||||
return md5.hexhash(normalized)
|
||||
}
|
||||
|
||||
// Store command state in Redis
|
||||
pub fn (mut p Pane) store_command_state(cmd string, status string, pid int) ! {
|
||||
key := p.get_state_key()
|
||||
cmd_hash := normalize_and_hash_command(cmd)
|
||||
now := time.now().format_ss_milli()
|
||||
|
||||
state := CommandState{
|
||||
cmd_md5: cmd_hash
|
||||
cmd_text: cmd
|
||||
status: status
|
||||
pid: pid
|
||||
started_at: now
|
||||
last_check: now
|
||||
pane_id: p.id
|
||||
}
|
||||
|
||||
state_json := json.encode(state)
|
||||
p.window.session.tmux.redis.set(key, state_json)!
|
||||
|
||||
console.print_debug('Stored command state for pane ${p.id}: ${cmd_hash[..8]}... status=${status}')
|
||||
}
|
||||
|
||||
// Retrieve command state from Redis
|
||||
pub fn (mut p Pane) get_command_state() ?CommandState {
|
||||
key := p.get_state_key()
|
||||
state_json := p.window.session.tmux.redis.get(key) or { return none }
|
||||
|
||||
if state_json.len == 0 {
|
||||
return none
|
||||
}
|
||||
|
||||
state := json.decode(CommandState, state_json) or {
|
||||
console.print_debug('Failed to decode command state for pane ${p.id}: ${err}')
|
||||
return none
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// Check if command has changed by comparing MD5 hashes
|
||||
pub fn (mut p Pane) has_command_changed(new_cmd string) bool {
|
||||
stored_state := p.get_command_state() or { return true }
|
||||
new_hash := normalize_and_hash_command(new_cmd)
|
||||
return stored_state.cmd_md5 != new_hash
|
||||
}
|
||||
|
||||
// Update command status in Redis
|
||||
pub fn (mut p Pane) update_command_status(status string) ! {
|
||||
mut stored_state := p.get_command_state() or { return }
|
||||
stored_state.status = status
|
||||
stored_state.last_check = time.now().format_ss_milli()
|
||||
|
||||
key := p.get_state_key()
|
||||
state_json := json.encode(stored_state)
|
||||
p.window.session.tmux.redis.set(key, state_json)!
|
||||
|
||||
console.print_debug('Updated command status for pane ${p.id}: ${status}')
|
||||
}
|
||||
|
||||
// Clear command state from Redis (when pane is reset or command is removed)
|
||||
pub fn (mut p Pane) clear_command_state() ! {
|
||||
key := p.get_state_key()
|
||||
p.window.session.tmux.redis.del(key) or {
|
||||
console.print_debug('Failed to clear command state for pane ${p.id}: ${err}')
|
||||
}
|
||||
console.print_debug('Cleared command state for pane ${p.id}')
|
||||
}
|
||||
|
||||
// Check if stored command is currently running by verifying the PID
|
||||
pub fn (mut p Pane) is_stored_command_running() bool {
|
||||
stored_state := p.get_command_state() or { return false }
|
||||
|
||||
if stored_state.pid <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use osal to check if process exists
|
||||
return osal.process_exists(stored_state.pid)
|
||||
}
|
||||
|
||||
// Get all command states for a session (useful for debugging/monitoring)
|
||||
pub fn (mut s Session) get_all_command_states() !map[string]CommandState {
|
||||
mut states := map[string]CommandState{}
|
||||
|
||||
// Get all keys matching the session pattern
|
||||
pattern := 'herotmux:${s.name}:*'
|
||||
keys := s.tmux.redis.keys(pattern)!
|
||||
|
||||
for key in keys {
|
||||
state_json := s.tmux.redis.get(key) or { continue }
|
||||
if state_json.len == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
state := json.decode(CommandState, state_json) or {
|
||||
console.print_debug('Failed to decode state for key ${key}: ${err}')
|
||||
continue
|
||||
}
|
||||
|
||||
states[key] = state
|
||||
}
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
// Clean up stale command states (for maintenance)
|
||||
pub fn (mut s Session) cleanup_stale_command_states() ! {
|
||||
states := s.get_all_command_states()!
|
||||
|
||||
for key, state in states {
|
||||
// Check if the process is still running
|
||||
if state.pid > 0 && !osal.process_exists(state.pid) {
|
||||
// Process is dead, update status
|
||||
mut updated_state := state
|
||||
updated_state.status = 'finished'
|
||||
updated_state.last_check = time.now().format_ss_milli()
|
||||
|
||||
state_json := json.encode(updated_state)
|
||||
s.tmux.redis.set(key, state_json)!
|
||||
|
||||
console.print_debug('Updated stale command state ${key}: process ${state.pid} no longer exists')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,8 @@ fn test_start() ! {
|
||||
// test server is running after start()
|
||||
tmux.start() or { panic('cannot start tmux: ${err}') }
|
||||
mut tmux_ls := osal.execute_silent('tmux ls') or { panic('Cannot execute tmux ls: ${err}') }
|
||||
// test started tmux contains windows
|
||||
assert tmux_ls.contains('init: 1 windows')
|
||||
// test started tmux contains some session
|
||||
assert tmux_ls.len > 0, 'Tmux should have at least one session'
|
||||
tmux.stop() or { panic('cannot stop tmux: ${err}') }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +1,57 @@
|
||||
module tmux
|
||||
|
||||
import freeflowuniverse.herolib.osal.core as osal
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
import rand
|
||||
import time
|
||||
|
||||
// uses single tmux instance for all tests
|
||||
// Simple tests for tmux functionality
|
||||
|
||||
fn testsuite_begin() {
|
||||
muttmux := new() or { panic('Cannot create tmux: ${err}') }
|
||||
// Test MD5 command hashing (doesn't require tmux)
|
||||
fn test_md5_hashing() ! {
|
||||
// Test basic hashing
|
||||
cmd1 := 'echo "test"'
|
||||
cmd2 := 'echo "test"'
|
||||
cmd3 := 'echo "different"'
|
||||
|
||||
// reset tmux for tests
|
||||
is_running := is_running() or { panic('cannot check if tmux is running: ${err}') }
|
||||
if is_running {
|
||||
stop() or { panic('Cannot stop tmux: ${err}') }
|
||||
}
|
||||
hash1 := normalize_and_hash_command(cmd1)
|
||||
hash2 := normalize_and_hash_command(cmd2)
|
||||
hash3 := normalize_and_hash_command(cmd3)
|
||||
|
||||
assert hash1 == hash2, 'Same commands should have same hash'
|
||||
assert hash1 != hash3, 'Different commands should have different hashes'
|
||||
|
||||
// Test normalization
|
||||
cmd_with_spaces := ' echo "test" '
|
||||
cmd_with_newlines := 'echo "test"\n'
|
||||
|
||||
hash_spaces := normalize_and_hash_command(cmd_with_spaces)
|
||||
hash_newlines := normalize_and_hash_command(cmd_with_newlines)
|
||||
|
||||
assert hash1 == hash_spaces, 'Commands with extra spaces should normalize to same hash'
|
||||
assert hash1 == hash_newlines, 'Commands with newlines should normalize to same hash'
|
||||
}
|
||||
|
||||
fn testsuite_end() {
|
||||
is_running := is_running() or { panic('cannot check if tmux is running: ${err}') }
|
||||
if is_running {
|
||||
stop() or { panic('Cannot stop tmux: ${err}') }
|
||||
// Test basic tmux functionality
|
||||
fn test_tmux_basic() ! {
|
||||
// Create unique session name to avoid conflicts
|
||||
session_name := 'test_${rand.int()}'
|
||||
|
||||
mut tmux_instance := new()!
|
||||
|
||||
// Ensure tmux is running
|
||||
if !tmux_instance.is_running()! {
|
||||
tmux_instance.start()!
|
||||
}
|
||||
}
|
||||
|
||||
fn test_window_new() ! {
|
||||
mut tmux := new()!
|
||||
tmux.start()!
|
||||
|
||||
// Create session first
|
||||
mut session := tmux.session_create(name: 'main')!
|
||||
// Create session
|
||||
mut session := tmux_instance.session_create(name: session_name)!
|
||||
// Note: session name gets normalized by name_fix, so we check if it contains our unique part
|
||||
assert session.name.contains('test_'), 'Session name should contain test_ prefix'
|
||||
|
||||
// Test window creation
|
||||
mut window := session.window_new(
|
||||
name: 'TestWindow'
|
||||
cmd: 'bash'
|
||||
reset: true
|
||||
)!
|
||||
mut window := session.window_new(name: 'testwin')!
|
||||
assert window.name == 'testwin'
|
||||
assert session.window_exist(name: 'testwin')
|
||||
|
||||
assert window.name == 'testwindow' // name_fix converts to lowercase
|
||||
assert session.window_exist(name: 'testwindow')
|
||||
|
||||
tmux.stop()!
|
||||
}
|
||||
|
||||
// tests creating duplicate windows
|
||||
fn test_window_new0() {
|
||||
installer := get_install()!
|
||||
|
||||
mut tmux := Tmux{
|
||||
node: node_ssh
|
||||
}
|
||||
|
||||
window_args := WindowArgs{
|
||||
name: 'TestWindow0'
|
||||
}
|
||||
|
||||
// console.print_debug(tmux)
|
||||
mut window := tmux.window_new(window_args) or { panic("Can't create new window: ${err}") }
|
||||
assert tmux.sessions.keys().contains('main')
|
||||
mut window_dup := tmux.window_new(window_args) or { panic("Can't create new window: ${err}") }
|
||||
console.print_debug(node_ssh.exec('tmux ls') or { panic('fail:${err}') })
|
||||
window.delete() or { panic('Cant delete window') }
|
||||
// console.print_debug(tmux)
|
||||
// Clean up - just stop tmux to clean everything
|
||||
tmux_instance.stop()!
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user