yatfa 1.0.80 → 1.0.82
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 +19 -0
- package/lib/yatfa_agent/agent.rb +54 -4
- package/lib/yatfa_agent/docker.rb +3 -0
- package/lib/yatfa_agent/installer.rb +251 -0
- package/lib/yatfa_agent/updater.rb +324 -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
|
|
@@ -66,6 +68,8 @@ def show_usage
|
|
|
66
68
|
puts " npx yatfa version # Show version"
|
|
67
69
|
puts " npx yatfa setup # Interactive setup wizard"
|
|
68
70
|
puts " npx yatfa [worker|planner|reviewer|researcher|claw] # Run agent (prompts to attach)"
|
|
71
|
+
puts " npx yatfa update-agents [--once] # Update stale agent containers"
|
|
72
|
+
puts " npx yatfa install-updater [--uninstall] # Install/remove auto-update service"
|
|
69
73
|
puts ""
|
|
70
74
|
puts "Options:"
|
|
71
75
|
puts " --no-prompt, -d Run without prompts (for CI/scripts)"
|
|
@@ -100,6 +104,21 @@ if command == "setup"
|
|
|
100
104
|
exit 0
|
|
101
105
|
end
|
|
102
106
|
|
|
107
|
+
# Handle update-agents command (runs on host, no project config needed)
|
|
108
|
+
if command == "update-agents"
|
|
109
|
+
once = ARGV.include?("--once")
|
|
110
|
+
interval = ENV.fetch("YATFA_UPDATE_INTERVAL", (4 * 60 * 60).to_s).to_i
|
|
111
|
+
YatfaAgent::Updater.run(once: once, interval: interval)
|
|
112
|
+
exit 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Handle install-updater command (runs on host, no project config needed)
|
|
116
|
+
if command == "install-updater"
|
|
117
|
+
uninstall = ARGV.include?("--uninstall")
|
|
118
|
+
YatfaAgent::Installer.run(uninstall: uninstall)
|
|
119
|
+
exit 0
|
|
120
|
+
end
|
|
121
|
+
|
|
103
122
|
# Resolve project root by walking up the directory tree.
|
|
104
123
|
# This allows running `npx yatfa` from subfolders inside the project.
|
|
105
124
|
# Skipped for setup (which creates new config in cwd).
|
package/lib/yatfa_agent/agent.rb
CHANGED
|
@@ -14,6 +14,19 @@ module YatfaAgent
|
|
|
14
14
|
!result.empty?
|
|
15
15
|
end
|
|
16
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
|
+
|
|
17
30
|
# Prompt user for input with a default value
|
|
18
31
|
def self.prompt(message, default: nil, options: nil)
|
|
19
32
|
loop do
|
|
@@ -47,6 +60,28 @@ module YatfaAgent
|
|
|
47
60
|
# Handle running container: prompt user for action
|
|
48
61
|
def self.handle_running_container(agent_type, container_name, config, agent_configs)
|
|
49
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
|
+
|
|
50
85
|
puts ""
|
|
51
86
|
|
|
52
87
|
if non_interactive?
|
|
@@ -55,7 +90,8 @@ module YatfaAgent
|
|
|
55
90
|
exit 0
|
|
56
91
|
end
|
|
57
92
|
|
|
58
|
-
|
|
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])
|
|
59
95
|
|
|
60
96
|
case choice
|
|
61
97
|
when "A"
|
|
@@ -135,9 +171,10 @@ module YatfaAgent
|
|
|
135
171
|
|
|
136
172
|
# Build Docker image only when we're about to create a new container
|
|
137
173
|
# (Restart, Recreate, or no existing container)
|
|
138
|
-
|
|
174
|
+
# build_image returns the remote version to avoid a redundant fetch
|
|
175
|
+
remote_version = Docker.build_image(config)
|
|
139
176
|
|
|
140
|
-
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)
|
|
141
178
|
|
|
142
179
|
success = system(*docker_cmd)
|
|
143
180
|
|
|
@@ -248,7 +285,7 @@ module YatfaAgent
|
|
|
248
285
|
|
|
249
286
|
private
|
|
250
287
|
|
|
251
|
-
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)
|
|
252
289
|
docker_cmd = [
|
|
253
290
|
"docker", "run", "-d",
|
|
254
291
|
"--name", container_name,
|
|
@@ -258,6 +295,19 @@ module YatfaAgent
|
|
|
258
295
|
"--tmpfs", "/rails/log"
|
|
259
296
|
]
|
|
260
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 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
|
+
|
|
261
311
|
# Inject custom environment variables from config
|
|
262
312
|
# Merge global env with agent-specific env (agent-specific takes precedence)
|
|
263
313
|
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,324 @@
|
|
|
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
|
+
# Try to find project root from mounted setup directory
|
|
192
|
+
mounts = config.dig("Mounts") || []
|
|
193
|
+
mounts.each do |mount|
|
|
194
|
+
next unless mount["Destination"] == "/tmp/setup"
|
|
195
|
+
source = mount["Source"]
|
|
196
|
+
# The source is .../setup, project root is its parent
|
|
197
|
+
return File.expand_path("..", source) if source && Dir.exist?(File.expand_path("..", source))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Try REPOSITORIES_CONFIG env var to find workspace path
|
|
201
|
+
env = config.dig("Config", "Env") || []
|
|
202
|
+
env.each do |var|
|
|
203
|
+
if var.start_with?("REPOSITORIES_CONFIG=")
|
|
204
|
+
begin
|
|
205
|
+
repos = JSON.parse(var.sub("REPOSITORIES_CONFIG=", ""))
|
|
206
|
+
if repos.is_a?(Array) && repos.any?
|
|
207
|
+
path = repos.first["path_in_sandbox"]
|
|
208
|
+
if path
|
|
209
|
+
parent = File.dirname(path)
|
|
210
|
+
# Walk up looking for yatfa.env.rb
|
|
211
|
+
dir = parent
|
|
212
|
+
while dir != "/"
|
|
213
|
+
return dir if File.exist?(File.join(dir, "yatfa.env.rb"))
|
|
214
|
+
dir = File.dirname(dir)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
rescue JSON::ParserError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Try CWD from mount sources
|
|
225
|
+
binds = config.dig("HostConfig", "Binds") || []
|
|
226
|
+
binds.each do |bind|
|
|
227
|
+
source = bind.split(":").first
|
|
228
|
+
next unless source
|
|
229
|
+
return File.dirname(source) if File.exist?(File.join(File.dirname(source), "yatfa.env.rb"))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Load config for a container from its project root
|
|
236
|
+
# @param project_root [String] path to project root
|
|
237
|
+
# @return [Hash] loaded config
|
|
238
|
+
def self.load_config_for_container(project_root)
|
|
239
|
+
config_file = File.join(project_root, "yatfa.env.rb")
|
|
240
|
+
if File.exist?(config_file)
|
|
241
|
+
config = eval(File.read(config_file), binding, config_file)
|
|
242
|
+
config = config.transform_keys(&:to_s)
|
|
243
|
+
|
|
244
|
+
# Normalize via JSON round-trip
|
|
245
|
+
JSON.parse(JSON.generate(config))
|
|
246
|
+
else
|
|
247
|
+
{}
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Build a docker run command to recreate a container with preserved config
|
|
252
|
+
# @param name [String] container name
|
|
253
|
+
# @param config [Hash] container inspect data
|
|
254
|
+
# @param new_version [String] target version (deployed_at)
|
|
255
|
+
# @return [Array<String>] docker command arguments
|
|
256
|
+
def self.build_recreate_command(name, config, new_version)
|
|
257
|
+
cmd = ["docker", "run", "-d", "--name", name]
|
|
258
|
+
|
|
259
|
+
# Preserve restart policy
|
|
260
|
+
restart = config.dig("HostConfig", "RestartPolicy", "Name")
|
|
261
|
+
cmd += ["--restart", restart || "unless-stopped"]
|
|
262
|
+
|
|
263
|
+
# Preserve network mode
|
|
264
|
+
network = config.dig("HostConfig", "NetworkMode")
|
|
265
|
+
cmd += ["--network", network] if network && network != "default"
|
|
266
|
+
|
|
267
|
+
# Preserve tmpfs mounts
|
|
268
|
+
tmpfs = config.dig("HostConfig", "Tmpfs") || {}
|
|
269
|
+
tmpfs.each_key do |path|
|
|
270
|
+
cmd += ["--tmpfs", path]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Preserve volume mounts (excluding setup mount which is dev-only)
|
|
274
|
+
mounts = config.dig("Mounts") || []
|
|
275
|
+
mounts.each do |mount|
|
|
276
|
+
src = mount["Source"]
|
|
277
|
+
dst = mount["Destination"]
|
|
278
|
+
mode = mount["Mode"] || "rw"
|
|
279
|
+
|
|
280
|
+
# Skip mounts that no longer exist on host
|
|
281
|
+
next unless File.exist?(src)
|
|
282
|
+
|
|
283
|
+
if mode == "ro"
|
|
284
|
+
cmd += ["-v", "#{src}:#{dst}:ro"]
|
|
285
|
+
else
|
|
286
|
+
cmd += ["-v", "#{src}:#{dst}"]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Preserve environment variables
|
|
291
|
+
env = config.dig("Config", "Env") || []
|
|
292
|
+
env.each do |var|
|
|
293
|
+
# Skip internal Docker env vars
|
|
294
|
+
next if var.start_with?("PATH=", "HOME=", "HOSTNAME=")
|
|
295
|
+
cmd += ["-e", var]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Add updated version label
|
|
299
|
+
cmd += ["--label", "#{VERSION_LABEL}=#{new_version}"]
|
|
300
|
+
|
|
301
|
+
# Preserve the original Entrypoint
|
|
302
|
+
# Docker --entrypoint only accepts the executable, not arguments.
|
|
303
|
+
# If entrypoint has multiple elements (e.g. ["/bin/sh", "-c"]), use only
|
|
304
|
+
# the first as --entrypoint and prepend the rest before CMD.
|
|
305
|
+
entrypoint = config.dig("Config", "Entrypoint")
|
|
306
|
+
entrypoint_extra = []
|
|
307
|
+
if entrypoint.is_a?(Array) && entrypoint.any?
|
|
308
|
+
cmd += ["--entrypoint", entrypoint[0]]
|
|
309
|
+
entrypoint_extra = entrypoint[1..]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Use the same image
|
|
313
|
+
image = config.dig("Config", "Image")
|
|
314
|
+
cmd += [image || "yatfa-sandbox"]
|
|
315
|
+
|
|
316
|
+
# Use the original CMD (prepended with any extra entrypoint args)
|
|
317
|
+
original_cmd = config.dig("Config", "Cmd")
|
|
318
|
+
full_cmd = entrypoint_extra + (original_cmd.is_a?(Array) ? original_cmd : [])
|
|
319
|
+
cmd += full_cmd if full_cmd.any?
|
|
320
|
+
|
|
321
|
+
cmd
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
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
|