yatfa 1.0.81 → 1.0.83
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.
- package/agents.rb +1 -1
- package/bin/run-yatfa-agent.rb +45 -5
- package/lib/yatfa_agent/agent.rb +14 -3
- package/lib/yatfa_agent/docker.rb +3 -0
- package/lib/yatfa_agent/installer.rb +251 -0
- package/lib/yatfa_agent/updater.rb +334 -0
- package/package.json +1 -1
- package/setup/setup-agent.rb +10 -1
package/agents.rb
CHANGED
|
@@ -224,7 +224,7 @@ AGENT_CONFIGS = {
|
|
|
224
224
|
|
|
225
225
|
'researcher' => {
|
|
226
226
|
name: 'yatfa-autonomous-researcher',
|
|
227
|
-
skills: ['researcher-tactical', 'researcher-strategic', 'researcher-digest', 'researcher-telegram-processor', 'memory', 'session-learnings', 'proposal-execution', 'proposal-rework-responder', 'memory-consolidation', 'retrospective', 'knowledge-management', 'sentry-investigation', 'agent-browser'],
|
|
227
|
+
skills: ['researcher-tactical', 'researcher-strategic', 'researcher-digest', 'researcher-telegram-processor', 'researcher-meta-learning', 'memory', 'session-learnings', 'proposal-execution', 'proposal-rework-responder', 'memory-consolidation', 'retrospective', 'knowledge-management', 'sentry-investigation', 'agent-browser'],
|
|
228
228
|
banner: build_banner(
|
|
229
229
|
header: RESEARCHER_HEADER,
|
|
230
230
|
boundaries: RESEARCHER_BOUNDARIES,
|
package/bin/run-yatfa-agent.rb
CHANGED
|
@@ -15,6 +15,8 @@ require_relative "../lib/yatfa_agent/config"
|
|
|
15
15
|
require_relative "../lib/yatfa_agent/docker"
|
|
16
16
|
require_relative "../lib/yatfa_agent/agent"
|
|
17
17
|
require_relative "../lib/yatfa_agent/setup"
|
|
18
|
+
require_relative "../lib/yatfa_agent/updater"
|
|
19
|
+
require_relative "../lib/yatfa_agent/installer"
|
|
18
20
|
require_relative "../agents"
|
|
19
21
|
|
|
20
22
|
def current_version
|
|
@@ -40,17 +42,38 @@ def check_version!
|
|
|
40
42
|
remote = latest_npm_version
|
|
41
43
|
return if remote.nil? || local == remote
|
|
42
44
|
|
|
43
|
-
#
|
|
45
|
+
# Guard against infinite re-exec loop
|
|
46
|
+
if ENV["YATFA_SELF_UPDATE"] == "1"
|
|
47
|
+
puts "⚠️ Still outdated after self-update: v#{local} (latest: v#{remote})"
|
|
48
|
+
puts " Run manually: npx yatfa@latest #{ARGV.join(' ')}"
|
|
49
|
+
return # continue anyway rather than blocking
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
puts "🔄 Auto-updating yatfa: v#{local} → v#{remote}..."
|
|
53
|
+
|
|
54
|
+
# Clean up ALL stale installs so npx fetches fresh from registry
|
|
44
55
|
begin
|
|
45
56
|
real_path = File.realpath(__FILE__)
|
|
46
57
|
install_dir = File.expand_path("../..", real_path) # bin/../.. → package root
|
|
47
|
-
FileUtils.rm_rf(install_dir)
|
|
58
|
+
FileUtils.rm_rf(install_dir) if install_dir.include?("node_modules/yatfa")
|
|
59
|
+
|
|
60
|
+
# Also clear npx cache (~/.npm/_npx/<hash>/node_modules/yatfa)
|
|
61
|
+
npx_cache = File.join(Dir.home, ".npm", "_npx")
|
|
62
|
+
if Dir.exist?(npx_cache)
|
|
63
|
+
Dir.glob(File.join(npx_cache, "*", "node_modules", "yatfa")).each do |cached|
|
|
64
|
+
FileUtils.rm_rf(cached)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
48
67
|
rescue StandardError
|
|
49
|
-
#
|
|
68
|
+
# Best-effort cleanup — fall through to re-exec regardless
|
|
50
69
|
end
|
|
51
70
|
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
# Re-exec with @latest to force a fresh fetch from the registry
|
|
72
|
+
ENV["YATFA_SELF_UPDATE"] = "1"
|
|
73
|
+
exec("npx", "yatfa@latest", *ARGV)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
puts "⚠️ Auto-update failed: #{e.message}"
|
|
76
|
+
puts " Run manually: npx yatfa@latest #{ARGV.join(' ')}"
|
|
54
77
|
exit 1
|
|
55
78
|
end
|
|
56
79
|
|
|
@@ -66,6 +89,8 @@ def show_usage
|
|
|
66
89
|
puts " npx yatfa version # Show version"
|
|
67
90
|
puts " npx yatfa setup # Interactive setup wizard"
|
|
68
91
|
puts " npx yatfa [worker|planner|reviewer|researcher|claw] # Run agent (prompts to attach)"
|
|
92
|
+
puts " npx yatfa update-agents [--once] # Update stale agent containers"
|
|
93
|
+
puts " npx yatfa install-updater [--uninstall] # Install/remove auto-update service"
|
|
69
94
|
puts ""
|
|
70
95
|
puts "Options:"
|
|
71
96
|
puts " --no-prompt, -d Run without prompts (for CI/scripts)"
|
|
@@ -100,6 +125,21 @@ if command == "setup"
|
|
|
100
125
|
exit 0
|
|
101
126
|
end
|
|
102
127
|
|
|
128
|
+
# Handle update-agents command (runs on host, no project config needed)
|
|
129
|
+
if command == "update-agents"
|
|
130
|
+
once = ARGV.include?("--once")
|
|
131
|
+
interval = ENV.fetch("YATFA_UPDATE_INTERVAL", (4 * 60 * 60).to_s).to_i
|
|
132
|
+
YatfaAgent::Updater.run(once: once, interval: interval)
|
|
133
|
+
exit 0
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Handle install-updater command (runs on host, no project config needed)
|
|
137
|
+
if command == "install-updater"
|
|
138
|
+
uninstall = ARGV.include?("--uninstall")
|
|
139
|
+
YatfaAgent::Installer.run(uninstall: uninstall)
|
|
140
|
+
exit 0
|
|
141
|
+
end
|
|
142
|
+
|
|
103
143
|
# Resolve project root by walking up the directory tree.
|
|
104
144
|
# This allows running `npx yatfa` from subfolders inside the project.
|
|
105
145
|
# Skipped for setup (which creates new config in cwd).
|
package/lib/yatfa_agent/agent.rb
CHANGED
|
@@ -171,9 +171,10 @@ module YatfaAgent
|
|
|
171
171
|
|
|
172
172
|
# Build Docker image only when we're about to create a new container
|
|
173
173
|
# (Restart, Recreate, or no existing container)
|
|
174
|
-
|
|
174
|
+
# build_image returns the remote version to avoid a redundant fetch
|
|
175
|
+
remote_version = Docker.build_image(config)
|
|
175
176
|
|
|
176
|
-
docker_cmd = build_docker_command(container_name, agent_type, config, agent_config, agent_def)
|
|
177
|
+
docker_cmd = build_docker_command(container_name, agent_type, config, agent_config, agent_def, remote_version: remote_version)
|
|
177
178
|
|
|
178
179
|
success = system(*docker_cmd)
|
|
179
180
|
|
|
@@ -284,7 +285,7 @@ module YatfaAgent
|
|
|
284
285
|
|
|
285
286
|
private
|
|
286
287
|
|
|
287
|
-
def self.build_docker_command(container_name, agent_type, config, agent_config, agent_def)
|
|
288
|
+
def self.build_docker_command(container_name, agent_type, config, agent_config, agent_def, remote_version: nil)
|
|
288
289
|
docker_cmd = [
|
|
289
290
|
"docker", "run", "-d",
|
|
290
291
|
"--name", container_name,
|
|
@@ -301,6 +302,16 @@ module YatfaAgent
|
|
|
301
302
|
docker_cmd += ["-e", "YATFA_VERSION_NUM=#{version}"] unless version.empty?
|
|
302
303
|
end
|
|
303
304
|
|
|
305
|
+
# Add yatfa.version label for updater daemon to detect stale containers
|
|
306
|
+
# Uses deployed_at 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
|
+
|
|
304
315
|
# Inject custom environment variables from config
|
|
305
316
|
# Merge global env with agent-specific env (agent-specific takes precedence)
|
|
306
317
|
merged_env = {}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module YatfaAgent
|
|
6
|
+
module Installer
|
|
7
|
+
SERVICE_NAME = "yatfa-update-agents"
|
|
8
|
+
|
|
9
|
+
# Install or uninstall the updater system service
|
|
10
|
+
# @param uninstall [Boolean] if true, remove the service
|
|
11
|
+
def self.run(uninstall: false)
|
|
12
|
+
os = detect_os
|
|
13
|
+
|
|
14
|
+
case os
|
|
15
|
+
when :linux
|
|
16
|
+
handle_systemd(uninstall: uninstall)
|
|
17
|
+
when :macos
|
|
18
|
+
handle_launchd(uninstall: uninstall)
|
|
19
|
+
else
|
|
20
|
+
puts "❌ Unsupported operating system: #{os}"
|
|
21
|
+
puts " Supported: Linux (systemd), macOS (launchd)"
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Detect the operating system
|
|
27
|
+
# @return [Symbol] :linux, :macos, or :unknown
|
|
28
|
+
def self.detect_os
|
|
29
|
+
case RUBY_PLATFORM
|
|
30
|
+
when /linux/
|
|
31
|
+
:linux
|
|
32
|
+
when /darwin/
|
|
33
|
+
:macos
|
|
34
|
+
else
|
|
35
|
+
:unknown
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if systemctl is available
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def self.systemd_available?
|
|
42
|
+
system("which systemctl", out: File::NULL, err: File::NULL)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ─── Systemd (Linux) ─────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def self.handle_systemd(uninstall: false)
|
|
48
|
+
unless systemd_available?
|
|
49
|
+
puts "❌ systemctl not found. Is systemd installed?"
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unit_path = systemd_unit_path
|
|
54
|
+
npx_path = find_npx_path
|
|
55
|
+
|
|
56
|
+
if uninstall
|
|
57
|
+
uninstall_systemd(unit_path)
|
|
58
|
+
else
|
|
59
|
+
install_systemd(unit_path, npx_path)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.systemd_unit_path
|
|
64
|
+
# Use user-level systemd (runs as current user, not root)
|
|
65
|
+
dir = File.expand_path("~/.config/systemd/user")
|
|
66
|
+
FileUtils.mkdir_p(dir)
|
|
67
|
+
File.join(dir, "#{SERVICE_NAME}.service")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.install_systemd(unit_path, npx_path)
|
|
71
|
+
project_root = Dir.pwd
|
|
72
|
+
|
|
73
|
+
unit_content = <<~UNIT
|
|
74
|
+
[Unit]
|
|
75
|
+
Description=YATFA Agent Updater - Auto-update agent containers
|
|
76
|
+
After=docker.service
|
|
77
|
+
|
|
78
|
+
[Service]
|
|
79
|
+
Type=simple
|
|
80
|
+
ExecStart=#{npx_path} yatfa update-agents
|
|
81
|
+
WorkingDirectory=#{project_root}
|
|
82
|
+
Restart=on-failure
|
|
83
|
+
RestartSec=60
|
|
84
|
+
Environment=PATH=#{File.dirname(npx_path)}:/usr/local/bin:/usr/bin:/bin
|
|
85
|
+
|
|
86
|
+
[Install]
|
|
87
|
+
WantedBy=default.target
|
|
88
|
+
UNIT
|
|
89
|
+
|
|
90
|
+
File.write(unit_path, unit_content)
|
|
91
|
+
puts "📝 Written systemd unit to #{unit_path}"
|
|
92
|
+
|
|
93
|
+
# Reload systemd
|
|
94
|
+
system("systemctl", "--user", "daemon-reload", out: File::NULL, err: File::NULL)
|
|
95
|
+
puts "✅ Reloaded systemd"
|
|
96
|
+
|
|
97
|
+
# Enable and start
|
|
98
|
+
system("systemctl", "--user", "enable", SERVICE_NAME, out: File::NULL, err: File::NULL)
|
|
99
|
+
puts "✅ Enabled #{SERVICE_NAME} (starts on login)"
|
|
100
|
+
|
|
101
|
+
if system("systemctl", "--user", "start", SERVICE_NAME, out: File::NULL, err: File::NULL)
|
|
102
|
+
puts "✅ Started #{SERVICE_NAME}"
|
|
103
|
+
else
|
|
104
|
+
puts "⚠️ Could not start #{SERVICE_NAME}. Check: systemctl --user status #{SERVICE_NAME}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Enable lingering so service runs even when user is logged out
|
|
108
|
+
username = ENV["USER"] || `whoami`.strip
|
|
109
|
+
unless system("loginctl", "enable-linger", username, out: File::NULL, err: File::NULL)
|
|
110
|
+
puts "⚠️ Could not enable linger. Service will stop when you log out."
|
|
111
|
+
puts " Fix: sudo loginctl enable-linger #{username}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
puts ""
|
|
115
|
+
puts "🎉 Updater installed as systemd user service"
|
|
116
|
+
puts ""
|
|
117
|
+
puts " Status: systemctl --user status #{SERVICE_NAME}"
|
|
118
|
+
puts " Logs: journalctl --user -u #{SERVICE_NAME} -f"
|
|
119
|
+
puts " Stop: systemctl --user stop #{SERVICE_NAME}"
|
|
120
|
+
puts " Uninstall: npx yatfa install-updater --uninstall"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.uninstall_systemd(unit_path)
|
|
124
|
+
# Stop and disable
|
|
125
|
+
system("systemctl", "--user", "stop", SERVICE_NAME, out: File::NULL, err: File::NULL)
|
|
126
|
+
system("systemctl", "--user", "disable", SERVICE_NAME, out: File::NULL, err: File::NULL)
|
|
127
|
+
|
|
128
|
+
# Remove unit file
|
|
129
|
+
FileUtils.rm_f(unit_path)
|
|
130
|
+
puts "🗑️ Removed #{unit_path}"
|
|
131
|
+
|
|
132
|
+
# Reload systemd
|
|
133
|
+
system("systemctl", "--user", "daemon-reload", out: File::NULL, err: File::NULL)
|
|
134
|
+
puts "✅ Reloaded systemd"
|
|
135
|
+
|
|
136
|
+
puts ""
|
|
137
|
+
puts "✅ Updater service uninstalled"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ─── Launchd (macOS) ─────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
def self.handle_launchd(uninstall: false)
|
|
143
|
+
plist_path = launchd_plist_path
|
|
144
|
+
npx_path = find_npx_path
|
|
145
|
+
|
|
146
|
+
if uninstall
|
|
147
|
+
uninstall_launchd(plist_path)
|
|
148
|
+
else
|
|
149
|
+
install_launchd(plist_path, npx_path)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.launchd_plist_path
|
|
154
|
+
dir = File.expand_path("~/Library/LaunchAgents")
|
|
155
|
+
FileUtils.mkdir_p(dir)
|
|
156
|
+
File.join(dir, "com.yatfa.#{SERVICE_NAME}.plist")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.install_launchd(plist_path, npx_path)
|
|
160
|
+
project_root = Dir.pwd
|
|
161
|
+
|
|
162
|
+
plist_content = <<~PLIST
|
|
163
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
164
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
165
|
+
<plist version="1.0">
|
|
166
|
+
<dict>
|
|
167
|
+
<key>Label</key>
|
|
168
|
+
<string>com.yatfa.#{SERVICE_NAME}</string>
|
|
169
|
+
<key>ProgramArguments</key>
|
|
170
|
+
<array>
|
|
171
|
+
<string>#{npx_path}</string>
|
|
172
|
+
<string>yatfa</string>
|
|
173
|
+
<string>update-agents</string>
|
|
174
|
+
</array>
|
|
175
|
+
<key>WorkingDirectory</key>
|
|
176
|
+
<string>#{project_root}</string>
|
|
177
|
+
<key>RunAtLoad</key>
|
|
178
|
+
<true/>
|
|
179
|
+
<key>KeepAlive</key>
|
|
180
|
+
<true/>
|
|
181
|
+
<key>StandardOutPath</key>
|
|
182
|
+
<string>#{File.expand_path("~/.yatfa/updater.log")}</string>
|
|
183
|
+
<key>StandardErrorPath</key>
|
|
184
|
+
<string>#{File.expand_path("~/.yatfa/updater.log")}</string>
|
|
185
|
+
<key>EnvironmentVariables</key>
|
|
186
|
+
<dict>
|
|
187
|
+
<key>PATH</key>
|
|
188
|
+
<string>#{File.dirname(npx_path)}:/usr/local/bin:/usr/bin:/bin</string>
|
|
189
|
+
</dict>
|
|
190
|
+
</dict>
|
|
191
|
+
</plist>
|
|
192
|
+
PLIST
|
|
193
|
+
|
|
194
|
+
# Create log directory
|
|
195
|
+
FileUtils.mkdir_p(File.expand_path("~/.yatfa"))
|
|
196
|
+
|
|
197
|
+
File.write(plist_path, plist_content)
|
|
198
|
+
puts "📝 Written launchd plist to #{plist_path}"
|
|
199
|
+
|
|
200
|
+
# Load the plist
|
|
201
|
+
system("launchctl", "load", plist_path, out: File::NULL, err: File::NULL)
|
|
202
|
+
puts "✅ Loaded #{SERVICE_NAME}"
|
|
203
|
+
|
|
204
|
+
puts ""
|
|
205
|
+
puts "🎉 Updater installed as launchd agent"
|
|
206
|
+
puts ""
|
|
207
|
+
puts " Logs: tail -f ~/.yatfa/updater.log"
|
|
208
|
+
puts " Unload: launchctl unload #{plist_path}"
|
|
209
|
+
puts " Uninstall: npx yatfa install-updater --uninstall"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def self.uninstall_launchd(plist_path)
|
|
213
|
+
# Unload first
|
|
214
|
+
system("launchctl", "unload", plist_path, out: File::NULL, err: File::NULL)
|
|
215
|
+
|
|
216
|
+
# Remove plist
|
|
217
|
+
FileUtils.rm_f(plist_path)
|
|
218
|
+
puts "🗑️ Removed #{plist_path}"
|
|
219
|
+
|
|
220
|
+
puts ""
|
|
221
|
+
puts "✅ Updater service uninstalled"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ─── Helpers ─────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
# Find the full path to npx
|
|
227
|
+
# @return [String] path to npx
|
|
228
|
+
def self.find_npx_path
|
|
229
|
+
result = `which npx 2>/dev/null`.strip
|
|
230
|
+
return result unless result.empty?
|
|
231
|
+
|
|
232
|
+
# Common paths to check
|
|
233
|
+
candidates = [
|
|
234
|
+
"/usr/local/bin/npx",
|
|
235
|
+
"/usr/bin/npx",
|
|
236
|
+
File.expand_path("~/.npm/bin/npx"),
|
|
237
|
+
File.expand_path("~/.nvm/versions/node/*/bin/npx")
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
candidates.each do |path|
|
|
241
|
+
# Handle glob patterns
|
|
242
|
+
Dir.glob(path).each do |expanded|
|
|
243
|
+
return expanded if File.executable?(expanded)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Fallback
|
|
248
|
+
"npx"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module YatfaAgent
|
|
7
|
+
module Updater
|
|
8
|
+
DEFAULT_CHECK_INTERVAL = 4 * 60 * 60 # 4 hours in seconds
|
|
9
|
+
VERSION_LABEL = "yatfa.version"
|
|
10
|
+
|
|
11
|
+
# Run the updater daemon
|
|
12
|
+
# @param once [Boolean] if true, run a single check and exit
|
|
13
|
+
# @param interval [Integer] check interval in seconds (default 4h)
|
|
14
|
+
def self.run(once: false, interval: DEFAULT_CHECK_INTERVAL)
|
|
15
|
+
puts "🔄 YATFA Agent Updater"
|
|
16
|
+
puts " Mode: #{once ? 'single check' : "daemon (every #{interval / 3600}h)"}"
|
|
17
|
+
puts ""
|
|
18
|
+
|
|
19
|
+
if once
|
|
20
|
+
perform_check
|
|
21
|
+
else
|
|
22
|
+
loop do
|
|
23
|
+
perform_check
|
|
24
|
+
puts ""
|
|
25
|
+
puts "💤 Next check in #{interval / 3600} hours (at #{(Time.now + interval).strftime('%H:%M')})"
|
|
26
|
+
sleep(interval)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Perform a single update check cycle
|
|
32
|
+
def self.perform_check
|
|
33
|
+
puts "🔍 [#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] Checking for updates..."
|
|
34
|
+
|
|
35
|
+
# 1. Fetch remote version
|
|
36
|
+
remote_version = Docker.fetch_remote_version
|
|
37
|
+
unless remote_version
|
|
38
|
+
puts "⚠️ Could not fetch remote version, skipping check"
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
puts " Remote version: #{remote_version[0..19]}..."
|
|
43
|
+
|
|
44
|
+
# 2. Find yatfa-managed containers
|
|
45
|
+
containers = list_yatfa_containers
|
|
46
|
+
if containers.empty?
|
|
47
|
+
puts " No yatfa-managed containers found"
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
puts " Found #{containers.size} yatfa-managed container(s)"
|
|
52
|
+
|
|
53
|
+
# 3. Check each container for staleness
|
|
54
|
+
stale = containers.select { |c| c[:version] != remote_version }
|
|
55
|
+
if stale.empty?
|
|
56
|
+
puts "✅ All containers are up to date"
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts " #{stale.size} container(s) need updates:"
|
|
61
|
+
stale.each do |c|
|
|
62
|
+
puts " - #{c[:name]} (version: #{c[:version]&.[](0..19) || 'unknown'})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 4. Update stale containers
|
|
66
|
+
stale.each do |container|
|
|
67
|
+
update_container(container, remote_version)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
puts ""
|
|
71
|
+
puts "✅ Update cycle complete (#{stale.size} updated)"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# List all Docker containers that have the yatfa.version label
|
|
75
|
+
# @return [Array<Hash>] array of container info hashes
|
|
76
|
+
def self.list_yatfa_containers
|
|
77
|
+
result = IO.popen(
|
|
78
|
+
["docker", "ps", "-a",
|
|
79
|
+
"--filter", "label=#{VERSION_LABEL}",
|
|
80
|
+
"--format", "{{.ID}}\t{{.Names}}\t{{.Status}}"],
|
|
81
|
+
err: File::NULL,
|
|
82
|
+
&:read
|
|
83
|
+
).strip
|
|
84
|
+
|
|
85
|
+
return [] if result.empty?
|
|
86
|
+
|
|
87
|
+
result.lines.map do |line|
|
|
88
|
+
id, name, status = line.strip.split("\t")
|
|
89
|
+
version = get_container_label(id, VERSION_LABEL)
|
|
90
|
+
{
|
|
91
|
+
id: id,
|
|
92
|
+
name: name,
|
|
93
|
+
status: status,
|
|
94
|
+
version: version
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Get a specific label value from a container
|
|
100
|
+
# @param container_id [String] container ID or name
|
|
101
|
+
# @param label [String] label key
|
|
102
|
+
# @return [String, nil] label value
|
|
103
|
+
def self.get_container_label(container_id, label)
|
|
104
|
+
result = IO.popen(
|
|
105
|
+
["docker", "inspect", "--format", "{{.Config.Labels.#{label}}}", container_id],
|
|
106
|
+
err: File::NULL,
|
|
107
|
+
&:read
|
|
108
|
+
).strip
|
|
109
|
+
result.empty? ? nil : result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Update a single stale container
|
|
113
|
+
# @param container [Hash] container info from list_yatfa_containers
|
|
114
|
+
# @param new_version [String] target version (deployed_at)
|
|
115
|
+
def self.update_container(container, new_version)
|
|
116
|
+
name = container[:name]
|
|
117
|
+
puts ""
|
|
118
|
+
puts "🔄 Updating #{name}..."
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
# 1. Extract container config before stopping
|
|
122
|
+
config = inspect_container_config(container[:id])
|
|
123
|
+
unless config
|
|
124
|
+
puts " ❌ Could not inspect container #{name}, skipping"
|
|
125
|
+
return
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 2. Build fresh image FIRST (while old container still running)
|
|
129
|
+
# This ensures the old container survives if the build fails.
|
|
130
|
+
# Both Config.load and Docker.build_image call exit(1) on failure,
|
|
131
|
+
# which SystemExit catches here (not caught by rescue StandardError).
|
|
132
|
+
puts " Building fresh image..."
|
|
133
|
+
begin
|
|
134
|
+
project_root = find_project_root_for_container(config)
|
|
135
|
+
if project_root && Dir.exist?(project_root)
|
|
136
|
+
Dir.chdir(project_root) do
|
|
137
|
+
docker_config = load_config_for_container(project_root)
|
|
138
|
+
Docker.build_image(docker_config)
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
puts " ⚠️ Could not find project root, attempting build in cwd"
|
|
142
|
+
docker_config = Config.load
|
|
143
|
+
Docker.build_image(docker_config)
|
|
144
|
+
end
|
|
145
|
+
rescue SystemExit => e
|
|
146
|
+
puts " ❌ Build failed (exit #{e.status}), keeping old container running"
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# 3. Only after successful build, stop and remove the old container
|
|
151
|
+
puts " Stopping old container..."
|
|
152
|
+
system("docker", "stop", name, out: File::NULL, err: File::NULL)
|
|
153
|
+
system("docker", "rm", name, out: File::NULL, err: File::NULL)
|
|
154
|
+
|
|
155
|
+
# 4. Recreate container with preserved config
|
|
156
|
+
puts " Starting new container..."
|
|
157
|
+
new_cmd = build_recreate_command(name, config, new_version)
|
|
158
|
+
success = system(*new_cmd)
|
|
159
|
+
|
|
160
|
+
if success
|
|
161
|
+
puts " ✅ #{name} updated successfully"
|
|
162
|
+
else
|
|
163
|
+
puts " ❌ Failed to start new container #{name}"
|
|
164
|
+
puts " Old container has been removed. Manual intervention may be needed."
|
|
165
|
+
end
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
puts " ❌ Error updating #{name}: #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Inspect a container and extract its configuration
|
|
172
|
+
# @param container_id [String] container ID
|
|
173
|
+
# @return [Hash, nil] parsed container config
|
|
174
|
+
def self.inspect_container_config(container_id)
|
|
175
|
+
result = IO.popen(
|
|
176
|
+
["docker", "inspect", "--format", "{{json .}}", container_id],
|
|
177
|
+
err: File::NULL,
|
|
178
|
+
&:read
|
|
179
|
+
).strip
|
|
180
|
+
return nil if result.empty?
|
|
181
|
+
|
|
182
|
+
JSON.parse(result)
|
|
183
|
+
rescue JSON::ParserError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Find the project root directory for a container based on its config
|
|
188
|
+
# @param config [Hash] container inspect data
|
|
189
|
+
# @return [String, nil] project root path
|
|
190
|
+
def self.find_project_root_for_container(config)
|
|
191
|
+
# Best: use the label stored at container creation time (works for all users)
|
|
192
|
+
label = config.dig("Config", "Labels", "yatfa.project-root")
|
|
193
|
+
if label && Dir.exist?(label)
|
|
194
|
+
return label
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Fallback: walk up from /tmp/setup mount source (dev-only, local clone of yatfa-public)
|
|
198
|
+
mounts = config.dig("Mounts") || []
|
|
199
|
+
mounts.each do |mount|
|
|
200
|
+
next unless mount["Destination"] == "/tmp/setup"
|
|
201
|
+
source = mount["Source"]
|
|
202
|
+
next unless source
|
|
203
|
+
dir = File.expand_path("..", source)
|
|
204
|
+
while dir != "/"
|
|
205
|
+
return dir if File.exist?(File.join(dir, "yatfa.env.rb"))
|
|
206
|
+
dir = File.dirname(dir)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Try REPOSITORIES_CONFIG env var to find workspace path
|
|
211
|
+
env = config.dig("Config", "Env") || []
|
|
212
|
+
env.each do |var|
|
|
213
|
+
if var.start_with?("REPOSITORIES_CONFIG=")
|
|
214
|
+
begin
|
|
215
|
+
repos = JSON.parse(var.sub("REPOSITORIES_CONFIG=", ""))
|
|
216
|
+
if repos.is_a?(Array) && repos.any?
|
|
217
|
+
path = repos.first["path_in_sandbox"]
|
|
218
|
+
if path
|
|
219
|
+
parent = File.dirname(path)
|
|
220
|
+
# Walk up looking for yatfa.env.rb
|
|
221
|
+
dir = parent
|
|
222
|
+
while dir != "/"
|
|
223
|
+
return dir if File.exist?(File.join(dir, "yatfa.env.rb"))
|
|
224
|
+
dir = File.dirname(dir)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
rescue JSON::ParserError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Try CWD from mount sources
|
|
235
|
+
binds = config.dig("HostConfig", "Binds") || []
|
|
236
|
+
binds.each do |bind|
|
|
237
|
+
source = bind.split(":").first
|
|
238
|
+
next unless source
|
|
239
|
+
return File.dirname(source) if File.exist?(File.join(File.dirname(source), "yatfa.env.rb"))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Load config for a container from its project root
|
|
246
|
+
# @param project_root [String] path to project root
|
|
247
|
+
# @return [Hash] loaded config
|
|
248
|
+
def self.load_config_for_container(project_root)
|
|
249
|
+
config_file = File.join(project_root, "yatfa.env.rb")
|
|
250
|
+
if File.exist?(config_file)
|
|
251
|
+
config = eval(File.read(config_file), binding, config_file)
|
|
252
|
+
config = config.transform_keys(&:to_s)
|
|
253
|
+
|
|
254
|
+
# Normalize via JSON round-trip
|
|
255
|
+
JSON.parse(JSON.generate(config))
|
|
256
|
+
else
|
|
257
|
+
{}
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Build a docker run command to recreate a container with preserved config
|
|
262
|
+
# @param name [String] container name
|
|
263
|
+
# @param config [Hash] container inspect data
|
|
264
|
+
# @param new_version [String] target version (deployed_at)
|
|
265
|
+
# @return [Array<String>] docker command arguments
|
|
266
|
+
def self.build_recreate_command(name, config, new_version)
|
|
267
|
+
cmd = ["docker", "run", "-d", "--name", name]
|
|
268
|
+
|
|
269
|
+
# Preserve restart policy
|
|
270
|
+
restart = config.dig("HostConfig", "RestartPolicy", "Name")
|
|
271
|
+
cmd += ["--restart", restart || "unless-stopped"]
|
|
272
|
+
|
|
273
|
+
# Preserve network mode
|
|
274
|
+
network = config.dig("HostConfig", "NetworkMode")
|
|
275
|
+
cmd += ["--network", network] if network && network != "default"
|
|
276
|
+
|
|
277
|
+
# Preserve tmpfs mounts
|
|
278
|
+
tmpfs = config.dig("HostConfig", "Tmpfs") || {}
|
|
279
|
+
tmpfs.each_key do |path|
|
|
280
|
+
cmd += ["--tmpfs", path]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Preserve volume mounts (excluding setup mount which is dev-only)
|
|
284
|
+
mounts = config.dig("Mounts") || []
|
|
285
|
+
mounts.each do |mount|
|
|
286
|
+
src = mount["Source"]
|
|
287
|
+
dst = mount["Destination"]
|
|
288
|
+
mode = mount["Mode"] || "rw"
|
|
289
|
+
|
|
290
|
+
# Skip mounts that no longer exist on host
|
|
291
|
+
next unless File.exist?(src)
|
|
292
|
+
|
|
293
|
+
if mode == "ro"
|
|
294
|
+
cmd += ["-v", "#{src}:#{dst}:ro"]
|
|
295
|
+
else
|
|
296
|
+
cmd += ["-v", "#{src}:#{dst}"]
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Preserve environment variables
|
|
301
|
+
env = config.dig("Config", "Env") || []
|
|
302
|
+
env.each do |var|
|
|
303
|
+
# Skip internal Docker env vars
|
|
304
|
+
next if var.start_with?("PATH=", "HOME=", "HOSTNAME=")
|
|
305
|
+
cmd += ["-e", var]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Add updated version label
|
|
309
|
+
cmd += ["--label", "#{VERSION_LABEL}=#{new_version}"]
|
|
310
|
+
|
|
311
|
+
# Preserve the original Entrypoint
|
|
312
|
+
# Docker --entrypoint only accepts the executable, not arguments.
|
|
313
|
+
# If entrypoint has multiple elements (e.g. ["/bin/sh", "-c"]), use only
|
|
314
|
+
# the first as --entrypoint and prepend the rest before CMD.
|
|
315
|
+
entrypoint = config.dig("Config", "Entrypoint")
|
|
316
|
+
entrypoint_extra = []
|
|
317
|
+
if entrypoint.is_a?(Array) && entrypoint.any?
|
|
318
|
+
cmd += ["--entrypoint", entrypoint[0]]
|
|
319
|
+
entrypoint_extra = entrypoint[1..]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Use the same image
|
|
323
|
+
image = config.dig("Config", "Image")
|
|
324
|
+
cmd += [image || "yatfa-sandbox"]
|
|
325
|
+
|
|
326
|
+
# Use the original CMD (prepended with any extra entrypoint args)
|
|
327
|
+
original_cmd = config.dig("Config", "Cmd")
|
|
328
|
+
full_cmd = entrypoint_extra + (original_cmd.is_a?(Array) ? original_cmd : [])
|
|
329
|
+
cmd += full_cmd if full_cmd.any?
|
|
330
|
+
|
|
331
|
+
cmd
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
package/package.json
CHANGED
package/setup/setup-agent.rb
CHANGED
|
@@ -330,6 +330,15 @@ def setup_claude_settings!
|
|
|
330
330
|
puts " Opus model: #{llm_credentials['model_opus']}"
|
|
331
331
|
end
|
|
332
332
|
|
|
333
|
+
# Configure context window if specified (value is in k-units, append 'k' suffix)
|
|
334
|
+
if llm_credentials["context_window"] && !llm_credentials["context_window"].to_s.empty?
|
|
335
|
+
context_window_value = llm_credentials["context_window"].to_i
|
|
336
|
+
if context_window_value > 0
|
|
337
|
+
settings_config["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "#{context_window_value}000"
|
|
338
|
+
puts " Context window: #{context_window_value}k tokens"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
333
342
|
if !llm_credentials["model_haiku"] && !llm_credentials["model_sonnet"] && !llm_credentials["model_opus"]
|
|
334
343
|
puts " Using default Anthropic models"
|
|
335
344
|
end
|
|
@@ -1035,4 +1044,4 @@ else
|
|
|
1035
1044
|
# prepare_git_state! is now in entrypoint.sh
|
|
1036
1045
|
bin_dir = download_agent_bridge!
|
|
1037
1046
|
run_agent!(bin_dir)
|
|
1038
|
-
end
|
|
1047
|
+
end
|