yatfa 1.0.88 → 1.0.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,150 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "container"
4
+ require_relative "command_builder"
5
+ require_relative "interaction"
6
+
3
7
  module YatfaAgent
8
+ # Agent orchestrator.
9
+ # Coordinates Container, CommandBuilder, and Interaction modules to run and attach agents.
10
+ # Sub-modules are mixed in via extend so their methods are available as Agent.xxx
11
+ # and test stubs (define_singleton_method) intercept internal calls correctly.
4
12
  module Agent
5
- # Check if container is currently running
6
- def self.container_running?(container_name)
7
- result = IO.popen(["docker", "ps", "--filter", "name=^#{container_name}$", "--format", "{{.Names}}"], err: File::NULL, &:read).strip
8
- !result.empty?
9
- end
10
-
11
- # Check if container exists (running or stopped)
12
- def self.container_exists?(container_name)
13
- result = IO.popen(["docker", "ps", "-a", "--filter", "name=^#{container_name}$", "--format", "{{.Names}}"], err: File::NULL, &:read).strip
14
- !result.empty?
15
- end
16
-
17
- # Get container creation time
18
- def self.container_creation_time(container_name)
19
- result = IO.popen(["docker", "inspect", "--format", "{{.Created}}", container_name], err: File::NULL, &:read).strip
20
- return nil if result.empty?
21
- Time.parse(result)
22
- rescue ArgumentError
23
- nil
24
- end
25
-
26
- def self.format_time(t)
27
- t.strftime("%H:%M")
28
- end
29
-
30
- # Prompt user for input with a default value
31
- def self.prompt(message, default: nil, options: nil)
32
- loop do
33
- print message
34
- input = $stdin.gets&.strip&.downcase || ""
35
-
36
- # Handle default on empty input
37
- if input.empty? && default
38
- return default
39
- end
40
-
41
- # If options specified, validate input
42
- if options
43
- valid = options.any? { |opt| opt.downcase == input || opt.downcase.start_with?(input) }
44
- if valid
45
- # Return the full option that matches
46
- return options.find { |opt| opt.downcase.start_with?(input) }
47
- end
48
- puts " Invalid option. Please choose: #{options.join('/')}"
49
- else
50
- return input
51
- end
52
- end
53
- end
54
-
55
- # Check if running in non-interactive mode
56
- def self.non_interactive?
57
- !$stdin.tty? || ENV['YATFA_NO_PROMPT'] == '1' || ARGV.include?('--no-prompt') || ARGV.include?('-d')
58
- end
59
-
60
- # Handle running container: prompt user for action
61
- def self.handle_running_container(agent_type, container_name, config, agent_configs)
62
- puts "⚠️ #{agent_type} agent is already running (#{container_name})"
63
-
64
- # Check if container was created before last deploy
65
- deploy_manifest = File.join(File.dirname(__FILE__), '..', '..', 'deploy', '.deploy-manifest.json')
66
- version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
67
- stale = false
68
-
69
- if File.exist?(version_file)
70
- current_version = File.read(version_file).strip
71
- container_version = IO.popen(["docker", "exec", container_name, "printenv", "YATFA_VERSION_NUM"], err: File::NULL, &:read).strip
72
- if !container_version.empty? && container_version != current_version
73
- puts " 🆕 New version deployed (container: #{container_version}, current: #{current_version})"
74
- stale = true
75
- end
76
- elsif File.exist?(deploy_manifest)
77
- deploy_time = File.mtime(deploy_manifest)
78
- container_created = container_creation_time(container_name)
79
- if container_created && container_created < deploy_time
80
- puts " 🆕 New deploy detected (container from #{format_time(container_created)}, deploy at #{format_time(deploy_time)})"
81
- stale = true
82
- end
83
- end
84
-
85
- puts ""
86
-
87
- if non_interactive?
88
- puts " Running in non-interactive mode. Run 'npx yatfa #{agent_type}' to attach, or stop the container first."
89
- puts " docker stop #{container_name}"
90
- exit 0
91
- end
92
-
93
- default_choice = stale ? "R" : "A"
94
- choice = prompt(" [A]ttach / [R]estart / [C]ancel? [#{default_choice == 'R' ? 'a/R/c' : 'A/r/c'}]: ", default: default_choice, options: %w[A R C])
95
-
96
- case choice
97
- when "A"
98
- puts ""
99
- attach(agent_type, config, agent_configs)
100
- when "R"
101
- puts ""
102
- puts "🔄 Stopping existing container..."
103
- system("docker", "stop", container_name, err: File::NULL, out: File::NULL)
104
- system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
105
- return true # Continue with start
106
- when "C"
107
- puts " Cancelled."
108
- exit 0
109
- end
110
- end
111
-
112
- # Banner file is no longer generated on the host.
113
- # System prompt is generated inside the container by setup-agent.rb
114
- # using AGENT_TYPE and REPOSITORIES_CONTEXT env vars.
115
-
116
- # Handle stopped container: prompt user for action
117
- def self.handle_stopped_container(agent_type, container_name, config, agent_configs)
118
- puts "⚠️ #{agent_type} agent container exists but is stopped (#{container_name})"
119
- puts ""
120
-
121
- if non_interactive?
122
- puts " Non-interactive mode: starting existing container."
123
- system("docker", "start", container_name, out: File::NULL)
124
- puts "✅ Agent started in background"
125
- prompt_to_attach(agent_type, container_name, config, agent_configs)
126
- exit 0
127
- end
128
-
129
- choice = prompt(" [S]tart existing / [R]ecreate / [C]ancel? [S/r/c]: ", default: "S", options: %w[S R C])
130
-
131
- case choice
132
- when "S"
133
- puts ""
134
- puts "🔄 Starting existing container..."
135
- system("docker", "start", container_name, out: File::NULL)
136
- puts "✅ Agent started in background"
137
- prompt_to_attach(agent_type, container_name, config, agent_configs)
138
- exit 0
139
- when "R"
140
- puts ""
141
- puts "🔄 Removing stopped container..."
142
- system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
143
- when "C"
144
- puts " Cancelled."
145
- exit 0
146
- end
147
- end
13
+ extend Container
14
+ extend CommandBuilder
15
+ extend Interaction
148
16
 
149
17
  def self.run(agent_type, config, agent_configs)
150
18
  unless agent_configs.keys.include?(agent_type)
@@ -190,37 +58,6 @@ module YatfaAgent
190
58
  end
191
59
  end
192
60
 
193
- # Prompt user to attach after starting a new agent
194
- def self.prompt_to_attach(agent_type, container_name, config, agent_configs)
195
- if non_interactive?
196
- # Non-interactive: just print helpful info
197
- puts " Logs: docker logs -f #{container_name}"
198
- puts " Stop: docker stop #{container_name}"
199
- puts " Attach: docker exec -it #{container_name} tmux attach -t agent"
200
- return
201
- end
202
-
203
- choice = prompt(" Agent started. Attach to session? [Y/n]: ", default: "Y", options: %w[Y N])
204
-
205
- if choice == "Y"
206
- puts ""
207
- # Wait briefly for container to initialize
208
- print " Waiting for agent to initialize"
209
- 5.times do
210
- print "."
211
- sleep 1
212
- end
213
- puts ""
214
- attach(agent_type, config, agent_configs)
215
- else
216
- puts ""
217
- puts " Agent is running in background."
218
- puts " Logs: docker logs -f #{container_name}"
219
- puts " Stop: docker stop #{container_name}"
220
- puts " Attach: docker exec -it #{container_name} tmux attach -t agent"
221
- end
222
- end
223
-
224
61
  def self.attach(agent_type, config, agent_configs)
225
62
  unless agent_configs.keys.include?(agent_type)
226
63
  puts "❌ Unknown agent type: #{agent_type}"
@@ -240,7 +77,7 @@ module YatfaAgent
240
77
  end
241
78
 
242
79
  puts "📎 Attaching to #{agent_type} agent..."
243
-
80
+
244
81
  user = detect_agent_user(container_name)
245
82
  puts " User: #{user}"
246
83
 
@@ -282,204 +119,5 @@ module YatfaAgent
282
119
  # Must run as agent user since tmux server runs under that user
283
120
  exec("docker", "exec", "-it", "-u", user, container_name, "tmux", "attach", "-t", "agent")
284
121
  end
285
-
286
- private
287
-
288
- def self.build_docker_command(container_name, agent_type, config, agent_config, agent_def, remote_version: nil)
289
- docker_cmd = [
290
- "docker", "run", "-d",
291
- "--name", container_name,
292
- "--network", "host",
293
- "--restart", "unless-stopped",
294
- "--tmpfs", "/rails/tmp",
295
- "--tmpfs", "/rails/log"
296
- ]
297
-
298
- # Inject current version for staleness detection
299
- version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
300
- if File.exist?(version_file)
301
- version = File.read(version_file).strip
302
- docker_cmd += ["-e", "YATFA_VERSION_NUM=#{version}"] unless version.empty?
303
- end
304
-
305
- # Add yatfa.version label for updater daemon to detect stale containers
306
- # Uses version number from remote version.json (already fetched by build_image)
307
- if remote_version
308
- docker_cmd += ["--label", "yatfa.version=#{remote_version}"]
309
- end
310
-
311
- # Store project root as a label so the updater daemon can find
312
- # Dockerfile.sandbox and yatfa.env.rb without reverse-engineering from mounts.
313
- docker_cmd += ["--label", "yatfa.project-root=#{Dir.pwd}"]
314
-
315
- # Inject custom environment variables from config
316
- # Merge global env with agent-specific env (agent-specific takes precedence)
317
- merged_env = {}
318
- merged_env.merge!(config["env"]) if config["env"]
319
- merged_env.merge!(agent_config["env"]) if agent_config["env"]
320
-
321
- if merged_env.any?
322
- merged_env.each do |k, v|
323
- docker_cmd += ["-e", "#{k}=#{v}"]
324
- end
325
- global_count = config["env"]&.size || 0
326
- agent_count = agent_config["env"]&.size || 0
327
- if agent_count > 0
328
- puts "🌿 Injected #{global_count} global + #{agent_count} agent-specific env vars"
329
- else
330
- puts "🌿 Injected #{global_count} custom env vars from config"
331
- end
332
- end
333
-
334
- if agent_type == "claw"
335
- # Claw-specific Docker command setup
336
- docker_cmd += [
337
- "-e", "AGENT_TYPE=#{agent_type}",
338
- "-e", "YATFA_VERSION=main"
339
- ]
340
-
341
- # Add persistence volume for OpenClaw config and data (scoped per container)
342
- docker_cmd += ["-v", "#{container_name}-openclaw-data:/home/yatfa/.openclaw"]
343
-
344
- # Auto-inject telegram_bot_token as env var so users don't need to duplicate in env block
345
- if agent_config["telegram_bot_token"]
346
- docker_cmd += ["-e", "OPENCLAW_TELEGRAM_BOT_TOKEN=#{agent_config['telegram_bot_token']}"]
347
- end
348
-
349
- # Pass YATFA_URL if configured (optional for claw)
350
- if config["yatfa_url"]
351
- docker_cmd += ["-e", "YATFA_URL=#{config['yatfa_url']}"]
352
- docker_cmd += ["-e", "YATFA_API_URL=#{config['yatfa_url']}/api/v1"]
353
- end
354
-
355
- # Pass MCP API key if configured (for future MCP integration)
356
- if agent_config["mcp_api_key"]
357
- docker_cmd += ["-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"]
358
- end
359
-
360
- add_development_mounts!(docker_cmd)
361
- else
362
- # Standard agent setup (worker, planner, reviewer, researcher)
363
- docker_cmd += [
364
- "-e", "YATFA_VERSION=main",
365
- "-e", "SKILLS=#{agent_def[:skills]&.join(',')}",
366
- "-e", "AGENT_TYPE=#{agent_type}",
367
- "-e", "YATFA_URL=#{YatfaAgent::Config.yatfa_url(config)}",
368
- "-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"
369
- ]
370
-
371
- add_github_auth!(docker_cmd, config)
372
- add_git_config!(docker_cmd, config)
373
- add_repository_mounts!(docker_cmd, config)
374
- add_development_mounts!(docker_cmd)
375
- end
376
-
377
- local_setup_dir = File.join(File.dirname(__FILE__), "..", "..", "setup")
378
-
379
- if File.directory?(local_setup_dir)
380
- docker_cmd += [Config.image_name(config), "ruby", "/tmp/setup/setup-agent.rb"]
381
- else
382
- docker_cmd += [Config.image_name(config)]
383
- end
384
-
385
- docker_cmd
386
- end
387
-
388
- def self.add_github_auth!(docker_cmd, config)
389
- github = config["github"] || {}
390
-
391
- # New method: GitHub authentication is handled by the backend
392
- # Agents get tokens via YATFA_API_KEY using the /github/token endpoint
393
- # No GitHub credentials need to be passed into the container
394
-
395
- # Deprecation warning for old GitHub App config
396
- if github["method"] == "app" || github["app_client_id"] || github["app_installation_id"]
397
- puts "⚠️ Warning: GitHub App configuration in yatfa.env.rb is deprecated"
398
- puts " GitHub credentials are now managed centrally in the Rails backend"
399
- puts " You can remove the 'github' section from yatfa.env.rb"
400
- puts " The backend will use its own GitHub App credentials to generate tokens"
401
- end
402
-
403
- # Optional: Support GH_TOKEN fallback for development/testing
404
- if github["token"]
405
- docker_cmd.concat(["-e", "GH_TOKEN=#{github['token']}"])
406
- puts "🔑 Using GitHub token authentication (fallback mode)"
407
- else
408
- puts "✅ GitHub authentication will be handled by backend (YATFA_API_KEY)"
409
- end
410
- end
411
-
412
- def self.add_git_config!(docker_cmd, config)
413
- return unless (git_config = config["git"])
414
-
415
- docker_cmd.concat(["-e", "GIT_USER_NAME=#{git_config['user_name']}"]) if git_config["user_name"]
416
- docker_cmd.concat(["-e", "GIT_USER_EMAIL=#{git_config['user_email']}"]) if git_config["user_email"]
417
- end
418
-
419
- def self.add_repository_mounts!(docker_cmd, config)
420
- return unless config["repositories"].is_a?(Array) && config["repositories"].any?
421
-
422
- puts "📚 Configuring #{config['repositories'].count} repositories..."
423
-
424
- # Build REPOSITORIES_CONTEXT for sessionstart hook (format: "name:path,name:path")
425
- sorted_repos = config["repositories"].sort_by { |r| r["position"] || 0 }
426
- repos_context = sorted_repos.map do |repo|
427
- repo_name = repo["name"]
428
- sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
429
- "#{repo_name}:#{sandbox_path}"
430
- end.join(",")
431
-
432
- docker_cmd.concat(["-e", "REPOSITORIES_CONTEXT=#{repos_context}"])
433
-
434
- # Pass repository config via environment variables for container to clone
435
- repo_config_json = sorted_repos.to_json
436
- docker_cmd.concat(["-e", "REPOSITORIES_CONFIG=#{repo_config_json}"])
437
-
438
- sorted_repos.each do |repo|
439
- repo_name = repo["name"]
440
- sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
441
- puts " - #{repo_name}: #{sandbox_path} (will be cloned in container)"
442
- end
443
- end
444
-
445
- def self.add_development_mounts!(docker_cmd)
446
- # Check for local setup/ directory (for development)
447
- setup_dir = File.join(File.dirname(__FILE__), "..", "..", "setup")
448
-
449
- if File.directory?(setup_dir)
450
- puts "🔧 Using local setup/ for development"
451
- docker_cmd.concat(["-v", "#{File.expand_path(setup_dir)}:/tmp/setup:ro"])
452
- end
453
- end
454
-
455
- def self.detect_agent_user(container_name)
456
- # Determine the user to attach as
457
- # Robust method: find the user running the agent process (tmux or bridge)
458
- user = `docker exec #{container_name} ps aux | grep "[a]gent-bridge-tmux" | awk '{print $1}' | head -n 1`.strip
459
-
460
- if user.empty?
461
- user = `docker exec #{container_name} ps aux | grep "[t]mux new-session" | awk '{print $1}' | head -n 1`.strip
462
- end
463
-
464
- if user.empty?
465
- # Fallback to previous heuristic
466
- detected_user = `docker exec #{container_name} whoami 2>/dev/null`.strip
467
- if detected_user == "root" || detected_user.empty?
468
- uid = Process.uid
469
- mapped_user = `docker exec #{container_name} getent passwd #{uid} | cut -d: -f1`.strip
470
- user = mapped_user unless mapped_user.empty?
471
- else
472
- user = detected_user
473
- end
474
- end
475
-
476
- if user.empty?
477
- # Final Fallback
478
- user = "rails"
479
- puts "⚠️ Could not detect agent user, defaulting to '#{user}'"
480
- end
481
-
482
- user
483
- end
484
122
  end
485
123
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module YatfaAgent
6
+ # Docker command assembly.
7
+ # Mixed into Agent via extend — methods become available as Agent.xxx.
8
+ # Responsible for building the full `docker run` command with all flags, env vars, and mounts.
9
+ module CommandBuilder
10
+ def build_docker_command(container_name, agent_type, config, agent_config, agent_def, remote_version: nil)
11
+ docker_cmd = [
12
+ "docker", "run", "-d",
13
+ "--name", container_name,
14
+ "--network", "host",
15
+ "--restart", "unless-stopped",
16
+ "--tmpfs", "/rails/tmp",
17
+ "--tmpfs", "/rails/log"
18
+ ]
19
+
20
+ # Inject current version for staleness detection
21
+ version_file = File.join(File.dirname(__FILE__), '..', '..', 'VERSION')
22
+ if File.exist?(version_file)
23
+ version = File.read(version_file).strip
24
+ docker_cmd += ["-e", "YATFA_VERSION_NUM=#{version}"] unless version.empty?
25
+ end
26
+
27
+ # Add yatfa.version label for updater daemon to detect stale containers
28
+ # Uses version number from remote version.json (already fetched by build_image)
29
+ if remote_version
30
+ docker_cmd += ["--label", "yatfa.version=#{remote_version}"]
31
+ end
32
+
33
+ # Store project root as a label so the updater daemon can find
34
+ # Dockerfile.sandbox and yatfa.env.rb without reverse-engineering from mounts.
35
+ docker_cmd += ["--label", "yatfa.project-root=#{Dir.pwd}"]
36
+
37
+ # Inject custom environment variables from config
38
+ # Merge global env with agent-specific env (agent-specific takes precedence)
39
+ merged_env = {}
40
+ merged_env.merge!(config["env"]) if config["env"]
41
+ merged_env.merge!(agent_config["env"]) if agent_config["env"]
42
+
43
+ if merged_env.any?
44
+ merged_env.each do |k, v|
45
+ docker_cmd += ["-e", "#{k}=#{v}"]
46
+ end
47
+ global_count = config["env"]&.size || 0
48
+ agent_count = agent_config["env"]&.size || 0
49
+ if agent_count > 0
50
+ puts "🌿 Injected #{global_count} global + #{agent_count} agent-specific env vars"
51
+ else
52
+ puts "🌿 Injected #{global_count} custom env vars from config"
53
+ end
54
+ end
55
+
56
+ if agent_type == "claw"
57
+ # Claw-specific Docker command setup
58
+ docker_cmd += [
59
+ "-e", "AGENT_TYPE=#{agent_type}",
60
+ "-e", "YATFA_VERSION=main"
61
+ ]
62
+
63
+ # Add persistence volume for OpenClaw config and data (scoped per container)
64
+ docker_cmd += ["-v", "#{container_name}-openclaw-data:/home/yatfa/.openclaw"]
65
+
66
+ # Auto-inject telegram_bot_token as env var so users don't need to duplicate in env block
67
+ if agent_config["telegram_bot_token"]
68
+ docker_cmd += ["-e", "OPENCLAW_TELEGRAM_BOT_TOKEN=#{agent_config['telegram_bot_token']}"]
69
+ end
70
+
71
+ # Pass YATFA_URL if configured (optional for claw)
72
+ if config["yatfa_url"]
73
+ docker_cmd += ["-e", "YATFA_URL=#{config['yatfa_url']}"]
74
+ docker_cmd += ["-e", "YATFA_API_URL=#{config['yatfa_url']}/api/v1"]
75
+ end
76
+
77
+ # Pass MCP API key if configured (for future MCP integration)
78
+ if agent_config["mcp_api_key"]
79
+ docker_cmd += ["-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"]
80
+ end
81
+
82
+ else
83
+ # Standard agent setup (worker, planner, reviewer, researcher)
84
+ docker_cmd += [
85
+ "-e", "YATFA_VERSION=main",
86
+ "-e", "SKILLS=#{agent_def[:skills]&.join(',')}",
87
+ "-e", "AGENT_TYPE=#{agent_type}",
88
+ "-e", "YATFA_URL=#{YatfaAgent::Config.yatfa_url(config)}",
89
+ "-e", "YATFA_API_KEY=#{agent_config['mcp_api_key']}"
90
+ ]
91
+
92
+ add_github_auth!(docker_cmd, config)
93
+ add_git_config!(docker_cmd, config)
94
+ add_repository_mounts!(docker_cmd, config)
95
+ end
96
+
97
+ docker_cmd += [Config.image_name(config)]
98
+
99
+ docker_cmd
100
+ end
101
+
102
+ def add_github_auth!(docker_cmd, config)
103
+ github = config["github"] || {}
104
+
105
+ # New method: GitHub authentication is handled by the backend
106
+ # Agents get tokens via YATFA_API_KEY using the /github/token endpoint
107
+ # No GitHub credentials need to be passed into the container
108
+
109
+ # Deprecation warning for old GitHub App config
110
+ if github["method"] == "app" || github["app_client_id"] || github["app_installation_id"]
111
+ puts "⚠️ Warning: GitHub App configuration in yatfa.env.rb is deprecated"
112
+ puts " GitHub credentials are now managed centrally in the Rails backend"
113
+ puts " You can remove the 'github' section from yatfa.env.rb"
114
+ puts " The backend will use its own GitHub App credentials to generate tokens"
115
+ end
116
+
117
+ # Optional: Support GH_TOKEN fallback for development/testing
118
+ if github["token"]
119
+ docker_cmd.concat(["-e", "GH_TOKEN=#{github['token']}"])
120
+ puts "🔑 Using GitHub token authentication (fallback mode)"
121
+ else
122
+ puts "✅ GitHub authentication will be handled by backend (YATFA_API_KEY)"
123
+ end
124
+ end
125
+
126
+ def add_git_config!(docker_cmd, config)
127
+ return unless (git_config = config["git"])
128
+
129
+ docker_cmd.concat(["-e", "GIT_USER_NAME=#{git_config['user_name']}"]) if git_config["user_name"]
130
+ docker_cmd.concat(["-e", "GIT_USER_EMAIL=#{git_config['user_email']}"]) if git_config["user_email"]
131
+ end
132
+
133
+ def add_repository_mounts!(docker_cmd, config)
134
+ return unless config["repositories"].is_a?(Array) && config["repositories"].any?
135
+
136
+ puts "📚 Configuring #{config['repositories'].count} repositories..."
137
+
138
+ # Build REPOSITORIES_CONTEXT for sessionstart hook (format: "name:path,name:path")
139
+ sorted_repos = config["repositories"].sort_by { |r| r["position"] || 0 }
140
+ repos_context = sorted_repos.map do |repo|
141
+ repo_name = repo["name"]
142
+ sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
143
+ "#{repo_name}:#{sandbox_path}"
144
+ end.join(",")
145
+
146
+ docker_cmd.concat(["-e", "REPOSITORIES_CONTEXT=#{repos_context}"])
147
+
148
+ # Pass repository config via environment variables for container to clone
149
+ repo_config_json = sorted_repos.to_json
150
+ docker_cmd.concat(["-e", "REPOSITORIES_CONFIG=#{repo_config_json}"])
151
+
152
+ sorted_repos.each do |repo|
153
+ repo_name = repo["name"]
154
+ sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
155
+ puts " - #{repo_name}: #{sandbox_path} (will be cloned in container)"
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module YatfaAgent
6
+ # Container state queries and user detection.
7
+ # Mixed into Agent via extend — methods become available as Agent.xxx.
8
+ module Container
9
+ # Check if container is currently running
10
+ def container_running?(container_name)
11
+ result = IO.popen(["docker", "ps", "--filter", "name=^#{container_name}$", "--format", "{{.Names}}"], err: File::NULL, &:read).strip
12
+ !result.empty?
13
+ end
14
+
15
+ # Check if container exists (running or stopped)
16
+ def container_exists?(container_name)
17
+ result = IO.popen(["docker", "ps", "-a", "--filter", "name=^#{container_name}$", "--format", "{{.Names}}"], err: File::NULL, &:read).strip
18
+ !result.empty?
19
+ end
20
+
21
+ # Get container creation time
22
+ def container_creation_time(container_name)
23
+ result = IO.popen(["docker", "inspect", "--format", "{{.Created}}", container_name], err: File::NULL, &:read).strip
24
+ return nil if result.empty?
25
+ Time.parse(result)
26
+ rescue ArgumentError
27
+ nil
28
+ end
29
+
30
+ def format_time(t)
31
+ t.strftime("%H:%M")
32
+ end
33
+
34
+ # Detect the user running the agent process inside the container
35
+ def detect_agent_user(container_name)
36
+ # Determine the user to attach as
37
+ # Robust method: find the user running the agent process (tmux or bridge)
38
+ user = `docker exec #{container_name} ps aux | grep "[a]gent-bridge-tmux" | awk '{print $1}' | head -n 1`.strip
39
+
40
+ if user.empty?
41
+ user = `docker exec #{container_name} ps aux | grep "[t]mux new-session" | awk '{print $1}' | head -n 1`.strip
42
+ end
43
+
44
+ if user.empty?
45
+ # Fallback to previous heuristic
46
+ detected_user = `docker exec #{container_name} whoami 2>/dev/null`.strip
47
+ if detected_user == "root" || detected_user.empty?
48
+ uid = Process.uid
49
+ mapped_user = `docker exec #{container_name} getent passwd #{uid} | cut -d: -f1`.strip
50
+ user = mapped_user unless mapped_user.empty?
51
+ else
52
+ user = detected_user
53
+ end
54
+ end
55
+
56
+ if user.empty?
57
+ # Final Fallback
58
+ user = "rails"
59
+ puts "⚠️ Could not detect agent user, defaulting to '#{user}'"
60
+ end
61
+
62
+ user
63
+ end
64
+ end
65
+ end