zillacore 0.0.1
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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/zillacore +1521 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/user_registry.rb +159 -0
- data/lib/zillacore/agents.rb +203 -0
- data/lib/zillacore/brain.rb +197 -0
- data/lib/zillacore/card_index.rb +389 -0
- data/lib/zillacore/config.rb +263 -0
- data/lib/zillacore/cron.rb +629 -0
- data/lib/zillacore/deployments.rb +258 -0
- data/lib/zillacore/handlers/discord.rb +1643 -0
- data/lib/zillacore/handlers/fizzy.rb +1249 -0
- data/lib/zillacore/handlers/github.rb +598 -0
- data/lib/zillacore/handlers/zoho.rb +487 -0
- data/lib/zillacore/helpers.rb +760 -0
- data/lib/zillacore/planning.rb +237 -0
- data/lib/zillacore/prompts.rb +620 -0
- data/lib/zillacore/sessions.rb +282 -0
- data/lib/zillacore/skills.rb +276 -0
- data/lib/zillacore/users.rb +76 -0
- data/lib/zillacore/version.rb +6 -0
- data/lib/zillacore/zoho_mail_api.rb +109 -0
- data/lib/zillacore.rb +10 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data/zillacore.gemspec +30 -0
- data.tar.gz.sig +2 -0
- metadata +235 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ZillaCore Cron — scheduled agent tasks
|
|
4
|
+
#
|
|
5
|
+
# Runs in a background thread, checks cron jobs every minute,
|
|
6
|
+
# dispatches agents with natural language prompts on schedule.
|
|
7
|
+
|
|
8
|
+
require "English"
|
|
9
|
+
require "time"
|
|
10
|
+
require "json"
|
|
11
|
+
|
|
12
|
+
CRON_CONFIG_FILE = File.join(ZILLACORE_DIR, "cron.json")
|
|
13
|
+
CRON_JOBS = {}
|
|
14
|
+
CRON_JOBS_MUTEX = Mutex.new
|
|
15
|
+
CRON_THREAD = { ref: nil }
|
|
16
|
+
|
|
17
|
+
# Parse cron expression (simplified: supports minute, hour, day, month, weekday)
|
|
18
|
+
# Format: "minute hour day month weekday" (e.g., "0 9 * * 1-5" = 9am weekdays)
|
|
19
|
+
# Also supports special strings: @hourly, @daily, @weekly, @monthly
|
|
20
|
+
# Also supports one-time timestamps: ISO8601 format (e.g., "2026-02-27T09:00:00-05:00")
|
|
21
|
+
# Also supports natural language: "tomorrow at 9am", "in 2 hours", "next monday at 3pm"
|
|
22
|
+
def parse_cron_expression(expr)
|
|
23
|
+
case expr
|
|
24
|
+
when "@hourly" then { minute: 0, hour: "*", day: "*", month: "*", weekday: "*" }
|
|
25
|
+
when "@daily" then { minute: 0, hour: 0, day: "*", month: "*", weekday: "*" }
|
|
26
|
+
when "@weekly" then { minute: 0, hour: 0, day: "*", month: "*", weekday: 0 }
|
|
27
|
+
when "@monthly" then { minute: 0, hour: 0, day: 1, month: "*", weekday: "*" }
|
|
28
|
+
else
|
|
29
|
+
# Try parsing as natural language or ISO8601 timestamp for one-time execution
|
|
30
|
+
timestamp = parse_natural_time(expr)
|
|
31
|
+
return { one_time: true, timestamp: timestamp } if timestamp
|
|
32
|
+
|
|
33
|
+
parts = expr.split
|
|
34
|
+
return nil unless parts.size == 5
|
|
35
|
+
|
|
36
|
+
{ minute: parts[0], hour: parts[1], day: parts[2], month: parts[3], weekday: parts[4] }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Parse natural language time expressions into absolute timestamps
|
|
41
|
+
def parse_natural_time(expr)
|
|
42
|
+
now = Time.now
|
|
43
|
+
|
|
44
|
+
# Try ISO8601 first
|
|
45
|
+
begin
|
|
46
|
+
return Time.parse(expr)
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
# Not ISO8601, try natural language
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# "tomorrow at HH:MM" or "tomorrow at HHam/pm"
|
|
52
|
+
if expr =~ /^tomorrow\s+at\s+(.+)$/i
|
|
53
|
+
time_str = Regexp.last_match(1)
|
|
54
|
+
tomorrow = now + 86_400
|
|
55
|
+
parsed_time = parse_time_of_day(time_str, tomorrow)
|
|
56
|
+
return parsed_time if parsed_time
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# "in X hours/minutes/days"
|
|
60
|
+
if expr =~ /^in\s+(\d+)\s+(hour|minute|day)s?$/i
|
|
61
|
+
amount = Regexp.last_match(1).to_i
|
|
62
|
+
unit = Regexp.last_match(2).downcase
|
|
63
|
+
case unit
|
|
64
|
+
when "minute" then return now + (amount * 60)
|
|
65
|
+
when "hour" then return now + (amount * 3600)
|
|
66
|
+
when "day" then return now + (amount * 86_400)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# "next monday/tuesday/etc at HH:MM"
|
|
71
|
+
weekdays = { "sunday" => 0, "monday" => 1, "tuesday" => 2, "wednesday" => 3,
|
|
72
|
+
"thursday" => 4, "friday" => 5, "saturday" => 6 }
|
|
73
|
+
if expr =~ /^next\s+(#{weekdays.keys.join("|")})\s+at\s+(.+)$/i
|
|
74
|
+
target_wday = weekdays[Regexp.last_match(1).downcase]
|
|
75
|
+
time_str = Regexp.last_match(2)
|
|
76
|
+
days_ahead = (target_wday - now.wday + 7) % 7
|
|
77
|
+
days_ahead = 7 if days_ahead.zero? # "next monday" means next week if today is monday
|
|
78
|
+
target_date = now + (days_ahead * 86_400)
|
|
79
|
+
parsed_time = parse_time_of_day(time_str, target_date)
|
|
80
|
+
return parsed_time if parsed_time
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Parse time of day (e.g., "9am", "3:30pm", "14:00") and combine with a date
|
|
87
|
+
def parse_time_of_day(time_str, date)
|
|
88
|
+
# "9am" or "3pm"
|
|
89
|
+
if time_str =~ /^(\d+)(am|pm)$/i
|
|
90
|
+
hour = convert_meridiem_hour(Regexp.last_match(1).to_i, Regexp.last_match(2).downcase)
|
|
91
|
+
return Time.new(date.year, date.month, date.day, hour, 0, 0, date.utc_offset)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# "9:30am" or "3:45pm"
|
|
95
|
+
if time_str =~ /^(\d+):(\d+)(am|pm)$/i
|
|
96
|
+
hour = convert_meridiem_hour(Regexp.last_match(1).to_i, Regexp.last_match(3).downcase)
|
|
97
|
+
minute = Regexp.last_match(2).to_i
|
|
98
|
+
return Time.new(date.year, date.month, date.day, hour, minute, 0, date.utc_offset)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# "14:00" (24-hour format)
|
|
102
|
+
if time_str =~ /^(\d+):(\d+)$/
|
|
103
|
+
hour = Regexp.last_match(1).to_i
|
|
104
|
+
minute = Regexp.last_match(2).to_i
|
|
105
|
+
return Time.new(date.year, date.month, date.day, hour, minute, 0, date.utc_offset)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Convert 12-hour format hour + meridiem to 24-hour format.
|
|
112
|
+
def convert_meridiem_hour(hour, meridiem)
|
|
113
|
+
hour = 0 if hour == 12 && meridiem == "am"
|
|
114
|
+
hour += 12 if meridiem == "pm" && hour < 12
|
|
115
|
+
hour
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Check if current time matches cron expression
|
|
119
|
+
def cron_matches?(cron_hash, time = Time.now)
|
|
120
|
+
return false unless cron_hash
|
|
121
|
+
|
|
122
|
+
# Handle one-time scheduled tasks
|
|
123
|
+
if cron_hash[:one_time]
|
|
124
|
+
target = cron_hash[:timestamp]
|
|
125
|
+
# Match if we're within the same minute as the target time
|
|
126
|
+
return time.year == target.year &&
|
|
127
|
+
time.month == target.month &&
|
|
128
|
+
time.day == target.day &&
|
|
129
|
+
time.hour == target.hour &&
|
|
130
|
+
time.min == target.min
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
minute_match = match_field?(cron_hash[:minute], time.min)
|
|
134
|
+
hour_match = match_field?(cron_hash[:hour], time.hour)
|
|
135
|
+
day_match = match_field?(cron_hash[:day], time.day)
|
|
136
|
+
month_match = match_field?(cron_hash[:month], time.month)
|
|
137
|
+
weekday_match = match_field?(cron_hash[:weekday], time.wday)
|
|
138
|
+
|
|
139
|
+
minute_match && hour_match && day_match && month_match && weekday_match
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def match_field?(pattern, value)
|
|
143
|
+
return true if pattern == "*"
|
|
144
|
+
|
|
145
|
+
# Handle ranges (e.g., "1-5")
|
|
146
|
+
if pattern.include?("-")
|
|
147
|
+
range_start, range_end = pattern.split("-").map(&:to_i)
|
|
148
|
+
return value.between?(range_start, range_end)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Handle lists (e.g., "1,3,5")
|
|
152
|
+
return pattern.split(",").map(&:to_i).include?(value) if pattern.include?(",")
|
|
153
|
+
|
|
154
|
+
# Handle step values (e.g., "*/5")
|
|
155
|
+
if pattern.include?("/")
|
|
156
|
+
base, step = pattern.split("/")
|
|
157
|
+
step = step.to_i
|
|
158
|
+
return (value % step).zero? if base == "*"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Exact match
|
|
162
|
+
pattern.to_i == value
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Load cron jobs from config
|
|
166
|
+
def load_cron_jobs
|
|
167
|
+
return {} unless File.exist?(CRON_CONFIG_FILE)
|
|
168
|
+
|
|
169
|
+
jobs = JSON.parse(File.read(CRON_CONFIG_FILE), symbolize_names: true)
|
|
170
|
+
|
|
171
|
+
# Deserialize timestamp strings back to Time objects for one-time jobs
|
|
172
|
+
jobs.each_value do |job|
|
|
173
|
+
next unless job[:parsed]
|
|
174
|
+
|
|
175
|
+
job[:parsed][:timestamp] = Time.parse(job[:parsed][:timestamp]) if job[:parsed][:one_time] && job[:parsed][:timestamp].is_a?(String)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
jobs
|
|
179
|
+
rescue JSON::ParserError => e
|
|
180
|
+
LOG.error "[Cron] Failed to parse cron config: #{e.message}"
|
|
181
|
+
{}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Save cron jobs to config
|
|
185
|
+
def save_cron_jobs(jobs)
|
|
186
|
+
FileUtils.mkdir_p(ZILLACORE_DIR)
|
|
187
|
+
File.write(CRON_CONFIG_FILE, JSON.pretty_generate(jobs))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Reload cron jobs from disk
|
|
191
|
+
def reload_cron_jobs!(force: false)
|
|
192
|
+
return unless file_changed?(CRON_CONFIG_FILE, force: force)
|
|
193
|
+
|
|
194
|
+
CRON_JOBS_MUTEX.synchronize do
|
|
195
|
+
CRON_JOBS.clear
|
|
196
|
+
CRON_JOBS.merge!(load_cron_jobs)
|
|
197
|
+
end
|
|
198
|
+
LOG.info "[Cron] Reloaded #{CRON_JOBS.size} cron jobs"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Add a new cron job
|
|
202
|
+
def add_cron_job(id:, schedule:, agent:, project:, prompt: nil, script: nil, enabled: true, model: nil, effort: nil, discord_channel_id: nil,
|
|
203
|
+
forum_title: nil, forum_reply_to_latest: false, repeat_count: nil)
|
|
204
|
+
parsed = parse_cron_expression(schedule)
|
|
205
|
+
return { error: "Invalid cron expression" } unless parsed
|
|
206
|
+
return { error: "Must provide either prompt or script, not both" } if prompt && script
|
|
207
|
+
return { error: "Must provide either prompt or script" } unless prompt || script
|
|
208
|
+
|
|
209
|
+
job = {
|
|
210
|
+
id: id,
|
|
211
|
+
schedule: schedule,
|
|
212
|
+
parsed: parsed,
|
|
213
|
+
agent: agent,
|
|
214
|
+
project: project,
|
|
215
|
+
model: model,
|
|
216
|
+
effort: effort,
|
|
217
|
+
prompt: prompt,
|
|
218
|
+
script: script,
|
|
219
|
+
enabled: enabled,
|
|
220
|
+
discord_channel_id: discord_channel_id,
|
|
221
|
+
forum_title: forum_title,
|
|
222
|
+
forum_reply_to_latest: forum_reply_to_latest,
|
|
223
|
+
repeat_count: repeat_count,
|
|
224
|
+
execution_count: 0,
|
|
225
|
+
created_at: Time.now.iso8601,
|
|
226
|
+
last_run: nil
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
CRON_JOBS_MUTEX.synchronize do
|
|
230
|
+
jobs = load_cron_jobs
|
|
231
|
+
jobs[id.to_sym] = job
|
|
232
|
+
save_cron_jobs(jobs)
|
|
233
|
+
CRON_JOBS[id.to_sym] = job
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
{ success: true, job: job }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Remove a cron job
|
|
240
|
+
def remove_cron_job(id)
|
|
241
|
+
CRON_JOBS_MUTEX.synchronize do
|
|
242
|
+
jobs = load_cron_jobs
|
|
243
|
+
removed = jobs.delete(id.to_sym)
|
|
244
|
+
save_cron_jobs(jobs)
|
|
245
|
+
CRON_JOBS.delete(id.to_sym)
|
|
246
|
+
removed ? { success: true } : { error: "Job not found" }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Enable/disable a cron job
|
|
251
|
+
def toggle_cron_job(id, enabled)
|
|
252
|
+
CRON_JOBS_MUTEX.synchronize do
|
|
253
|
+
jobs = load_cron_jobs
|
|
254
|
+
job = jobs[id.to_sym]
|
|
255
|
+
return { error: "Job not found" } unless job
|
|
256
|
+
|
|
257
|
+
job[:enabled] = enabled
|
|
258
|
+
jobs[id.to_sym] = job
|
|
259
|
+
save_cron_jobs(jobs)
|
|
260
|
+
CRON_JOBS[id.to_sym] = job
|
|
261
|
+
{ success: true, job: job }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Update a cron job's schedule, discord channel, and/or forum title
|
|
266
|
+
def update_cron_job(id, schedule: nil, discord_channel_id: nil, forum_title: nil, forum_reply_to_latest: nil)
|
|
267
|
+
return { error: "No updates provided" } if schedule.nil? && discord_channel_id.nil? && forum_title.nil? && forum_reply_to_latest.nil?
|
|
268
|
+
|
|
269
|
+
if schedule
|
|
270
|
+
parsed = parse_cron_expression(schedule)
|
|
271
|
+
return { error: "Invalid cron expression" } unless parsed
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
CRON_JOBS_MUTEX.synchronize do
|
|
275
|
+
jobs = load_cron_jobs
|
|
276
|
+
job = jobs[id.to_sym]
|
|
277
|
+
return { error: "Job not found" } unless job
|
|
278
|
+
|
|
279
|
+
if schedule
|
|
280
|
+
job[:schedule] = schedule
|
|
281
|
+
job[:parsed] = parsed
|
|
282
|
+
end
|
|
283
|
+
job[:discord_channel_id] = discord_channel_id if discord_channel_id
|
|
284
|
+
job[:forum_title] = forum_title if forum_title
|
|
285
|
+
job[:forum_reply_to_latest] = forum_reply_to_latest unless forum_reply_to_latest.nil?
|
|
286
|
+
jobs[id.to_sym] = job
|
|
287
|
+
save_cron_jobs(jobs)
|
|
288
|
+
CRON_JOBS[id.to_sym] = job
|
|
289
|
+
{ success: true, job: job }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Execute a script-based cron job (no agent, direct script execution)
|
|
294
|
+
def execute_script_job(job, project)
|
|
295
|
+
script_path = File.expand_path(job[:script])
|
|
296
|
+
|
|
297
|
+
unless File.exist?(script_path)
|
|
298
|
+
LOG.error "[Cron] Script not found: #{script_path}"
|
|
299
|
+
return
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
unless File.executable?(script_path)
|
|
303
|
+
LOG.error "[Cron] Script not executable: #{script_path}"
|
|
304
|
+
return
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
308
|
+
log_file = File.join(project["repo_path"], "tmp/cron-script-#{job[:id]}-#{timestamp}.log")
|
|
309
|
+
FileUtils.mkdir_p(File.dirname(log_file))
|
|
310
|
+
|
|
311
|
+
draft_file = prepare_script_discord_draft(job, timestamp) if job[:discord_channel_id]
|
|
312
|
+
|
|
313
|
+
LOG.info "[Cron] Running script #{script_path} for job #{job[:id]}, tail -f #{log_file}"
|
|
314
|
+
|
|
315
|
+
pid = spawn(script_path,
|
|
316
|
+
chdir: project["repo_path"],
|
|
317
|
+
out: [log_file, "w"],
|
|
318
|
+
err: %i[child out])
|
|
319
|
+
|
|
320
|
+
Thread.new do
|
|
321
|
+
Process.wait(pid)
|
|
322
|
+
LOG.info "[Cron] Script job #{job[:id]} finished (exit: #{$CHILD_STATUS.exitstatus})"
|
|
323
|
+
deliver_script_output(job, log_file, draft_file)
|
|
324
|
+
update_cron_job_state(job)
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
LOG.error "[Cron] Script job #{job[:id]} failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Prepare a Discord draft file and meta for a script job. Returns the draft file path.
|
|
331
|
+
def prepare_script_discord_draft(job, timestamp)
|
|
332
|
+
draft_file = File.join(DISCORD_DRAFT_DIR, "cron-script-#{timestamp}-#{job[:id]}.md")
|
|
333
|
+
meta_file = "#{draft_file}.meta.json"
|
|
334
|
+
|
|
335
|
+
FileUtils.mkdir_p(File.dirname(draft_file))
|
|
336
|
+
|
|
337
|
+
script_agent_key = job[:agent]&.downcase&.gsub(/[^a-z0-9-]/, "-")
|
|
338
|
+
meta = {
|
|
339
|
+
channel_id: job[:discord_channel_id],
|
|
340
|
+
agent_key: script_agent_key,
|
|
341
|
+
agent_name: job[:agent] || "Script",
|
|
342
|
+
cron_job_id: job[:id],
|
|
343
|
+
forum_title: job[:forum_title],
|
|
344
|
+
forum_reply_to_latest: job[:forum_reply_to_latest],
|
|
345
|
+
created_at: Time.now.iso8601
|
|
346
|
+
}
|
|
347
|
+
File.write(meta_file, JSON.pretty_generate(meta))
|
|
348
|
+
draft_file
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Read script output and write to draft file or log.
|
|
352
|
+
def deliver_script_output(job, log_file, draft_file)
|
|
353
|
+
return unless File.exist?(log_file)
|
|
354
|
+
|
|
355
|
+
output = File.read(log_file).strip
|
|
356
|
+
|
|
357
|
+
if job[:discord_channel_id] && draft_file && !output.empty?
|
|
358
|
+
File.write(draft_file, output)
|
|
359
|
+
LOG.info "[Cron] Script output written to #{draft_file} (#{output.length} chars)"
|
|
360
|
+
elsif !output.empty?
|
|
361
|
+
LOG.info "[Cron] Script output: #{output[0..200]}..."
|
|
362
|
+
else
|
|
363
|
+
LOG.warn "[Cron] Script produced no output"
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Build the prompt content and meta files for a cron job
|
|
368
|
+
def build_cron_prompt(job, project)
|
|
369
|
+
prompt = job[:prompt]
|
|
370
|
+
agent_name = job[:agent]
|
|
371
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
372
|
+
|
|
373
|
+
if job[:discord_channel_id]
|
|
374
|
+
draft_file = File.join(DISCORD_DRAFT_DIR, "cron-#{timestamp}-#{agent_name}-#{job[:id]}.md")
|
|
375
|
+
meta_file = "#{draft_file}.meta.json"
|
|
376
|
+
FileUtils.mkdir_p(File.dirname(draft_file))
|
|
377
|
+
|
|
378
|
+
agent_key = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
379
|
+
meta = {
|
|
380
|
+
channel_id: job[:discord_channel_id],
|
|
381
|
+
agent_key: agent_key,
|
|
382
|
+
agent_name: agent_name,
|
|
383
|
+
cron_job_id: job[:id],
|
|
384
|
+
forum_title: job[:forum_title],
|
|
385
|
+
forum_reply_to_latest: job[:forum_reply_to_latest],
|
|
386
|
+
created_at: Time.now.iso8601
|
|
387
|
+
}
|
|
388
|
+
File.write(meta_file, JSON.pretty_generate(meta))
|
|
389
|
+
|
|
390
|
+
full_prompt = <<~PROMPT
|
|
391
|
+
## Scheduled Task (Discord Posting)
|
|
392
|
+
This is a scheduled cron job that will post to Discord channel #{job[:discord_channel_id]}.
|
|
393
|
+
|
|
394
|
+
You were asked to: "#{prompt}"
|
|
395
|
+
|
|
396
|
+
Project: #{job[:project]}
|
|
397
|
+
Source directory: #{project["repo_path"]}
|
|
398
|
+
|
|
399
|
+
**IMPORTANT: Write your response to #{draft_file}. Do NOT reply via stdout.**
|
|
400
|
+
Your response will be automatically posted to Discord.
|
|
401
|
+
|
|
402
|
+
#{prompt}
|
|
403
|
+
PROMPT
|
|
404
|
+
|
|
405
|
+
{ response_file: draft_file, meta_file: meta_file, full_prompt: full_prompt }
|
|
406
|
+
else
|
|
407
|
+
response_file = File.join(ZILLACORE_DIR, "tmp", "cron", "cron-#{job[:id]}-#{Time.now.to_i}.md")
|
|
408
|
+
FileUtils.mkdir_p(File.dirname(response_file))
|
|
409
|
+
|
|
410
|
+
full_prompt = <<~PROMPT
|
|
411
|
+
## Scheduled Task
|
|
412
|
+
This is a scheduled cron job. You were asked to: "#{prompt}"
|
|
413
|
+
|
|
414
|
+
Project: #{job[:project]}
|
|
415
|
+
Source directory: #{project["repo_path"]}
|
|
416
|
+
|
|
417
|
+
Write your response to: #{response_file}
|
|
418
|
+
|
|
419
|
+
#{prompt}
|
|
420
|
+
PROMPT
|
|
421
|
+
|
|
422
|
+
{ response_file: response_file, meta_file: nil, full_prompt: full_prompt }
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Handle post-execution: extract response from log, update job state
|
|
427
|
+
def handle_cron_completion(job, project, agent_name, agent_config_name, log_file, response_file, meta_file)
|
|
428
|
+
cron_exit_status = $CHILD_STATUS.exitstatus
|
|
429
|
+
LOG.info "[Cron] Job #{job[:id]} finished (exit: #{cron_exit_status})"
|
|
430
|
+
|
|
431
|
+
if cron_exit_status && cron_exit_status != 0 && job[:discord_channel_id]
|
|
432
|
+
bot_token = discord_bot_tokens[agent_config_name] || discord_bot_tokens.values.first
|
|
433
|
+
if bot_token
|
|
434
|
+
notify_agent_crash(
|
|
435
|
+
exit_status: cron_exit_status, log_file: log_file,
|
|
436
|
+
agent_name: agent_name, source: :discord,
|
|
437
|
+
source_context: { channel_id: job[:discord_channel_id], bot_token: bot_token },
|
|
438
|
+
project_config: project
|
|
439
|
+
)
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file)
|
|
444
|
+
|
|
445
|
+
qmd_out, qmd_status = Open3.capture2e("qmd", "update")
|
|
446
|
+
if qmd_status.success?
|
|
447
|
+
LOG.info "[Brain] qmd update completed after cron job #{job[:id]}"
|
|
448
|
+
else
|
|
449
|
+
LOG.warn "[Brain] qmd update failed: #{qmd_out.strip}"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
brain_push(message: "#{agent_config_name}: cron-#{job[:id]}")
|
|
453
|
+
update_cron_job_state(job)
|
|
454
|
+
|
|
455
|
+
if File.exist?(response_file)
|
|
456
|
+
LOG.info "[Cron] Job #{job[:id]} completed. Response: #{File.read(response_file)[0..100]}..."
|
|
457
|
+
else
|
|
458
|
+
LOG.warn "[Cron] Job #{job[:id]} produced no response"
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Extract agent response from log if the response file wasn't written directly
|
|
463
|
+
def extract_cron_response_from_log(job, agent_config_name, log_file, response_file, meta_file)
|
|
464
|
+
return if File.exist?(response_file)
|
|
465
|
+
return unless File.exist?(log_file)
|
|
466
|
+
|
|
467
|
+
log_content = File.read(log_file)
|
|
468
|
+
|
|
469
|
+
if log_content.match?(/Opening browser\.\.\.|Press \(\^\) \+ C to cancel/)
|
|
470
|
+
LOG.error "[Cron] Auth failure detected for job #{job[:id]} — " \
|
|
471
|
+
"re-authenticate with: kiro-cli --agent #{agent_config_name} chat"
|
|
472
|
+
File.delete(meta_file) if meta_file && File.exist?(meta_file)
|
|
473
|
+
return
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
clean_output = log_content
|
|
477
|
+
.gsub(/\e\[[0-9;]*[a-zA-Z]|\e\[\?[0-9;]*[a-zA-Z]/, "")
|
|
478
|
+
.gsub(/\e\][^\a]*\a/, "")
|
|
479
|
+
.delete("\r")
|
|
480
|
+
.gsub(/^.*?(using tool:.*?)$/m, "")
|
|
481
|
+
.gsub(/^.*?✓.*?$/m, "")
|
|
482
|
+
.gsub(/^.*?▸.*?$/m, "")
|
|
483
|
+
.gsub(/^.*?Loading\.\.\..*?$/m, "")
|
|
484
|
+
.gsub(/^.*?Completed in.*?$/m, "")
|
|
485
|
+
.strip
|
|
486
|
+
|
|
487
|
+
return unless !clean_output.empty? && clean_output.length > 20
|
|
488
|
+
|
|
489
|
+
File.write(response_file, clean_output)
|
|
490
|
+
LOG.info "[Cron] Extracted response from log (#{clean_output.length} chars)"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Update cron job state after execution (last_run, execution_count, auto-disable)
|
|
494
|
+
def update_cron_job_state(job)
|
|
495
|
+
CRON_JOBS_MUTEX.synchronize do
|
|
496
|
+
jobs = load_cron_jobs
|
|
497
|
+
job_data = jobs[job[:id].to_sym]
|
|
498
|
+
return unless job_data
|
|
499
|
+
|
|
500
|
+
job_data[:last_run] = Time.now.iso8601
|
|
501
|
+
job_data[:execution_count] = (job_data[:execution_count] || 0) + 1
|
|
502
|
+
|
|
503
|
+
if job[:parsed][:one_time]
|
|
504
|
+
job_data[:enabled] = false
|
|
505
|
+
CRON_JOBS[job[:id].to_sym][:enabled] = false
|
|
506
|
+
LOG.info "[Cron] Auto-disabled one-time job: #{job[:id]}"
|
|
507
|
+
elsif job[:repeat_count] && job_data[:execution_count] >= job[:repeat_count]
|
|
508
|
+
job_data[:enabled] = false
|
|
509
|
+
CRON_JOBS[job[:id].to_sym][:enabled] = false
|
|
510
|
+
LOG.info "[Cron] Auto-disabled job #{job[:id]} after #{job[:repeat_count]} executions"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
save_cron_jobs(jobs)
|
|
514
|
+
CRON_JOBS[job[:id].to_sym][:last_run] = Time.now.iso8601
|
|
515
|
+
CRON_JOBS[job[:id].to_sym][:execution_count] = job_data[:execution_count]
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Execute a cron job (dispatch agent)
|
|
520
|
+
def execute_cron_job(job)
|
|
521
|
+
return unless job[:enabled]
|
|
522
|
+
|
|
523
|
+
LOG.info "[Cron] Executing job #{job[:id]}: #{job[:prompt] || job[:script]}..."
|
|
524
|
+
|
|
525
|
+
project = PROJECTS[job[:project]]
|
|
526
|
+
unless project
|
|
527
|
+
LOG.error "[Cron] Project #{job[:project]} not found for job #{job[:id]}"
|
|
528
|
+
return
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
if job[:script]
|
|
532
|
+
execute_script_job(job, project)
|
|
533
|
+
return
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
agent_name = job[:agent]
|
|
537
|
+
agent_config_name = agent_name.downcase.gsub(/[^a-z0-9-]/, "-")
|
|
538
|
+
prompt_data = build_cron_prompt(job, project)
|
|
539
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
540
|
+
|
|
541
|
+
log_file = File.join(project["repo_path"], "tmp/agent-cron-#{job[:id]}-#{timestamp}.log")
|
|
542
|
+
FileUtils.mkdir_p(File.dirname(log_file))
|
|
543
|
+
|
|
544
|
+
prompt_file = write_cron_prompt_file(job, prompt_data[:full_prompt], timestamp)
|
|
545
|
+
cmd = build_cron_agent_cmd(job, project)
|
|
546
|
+
|
|
547
|
+
LOG.info "[Cron] Dispatching job #{job[:id]} with #{agent_name}, tail -f #{log_file}"
|
|
548
|
+
|
|
549
|
+
spawn_env = agent_env_for(agent_name)
|
|
550
|
+
LOG.info "[Cron] Injecting #{spawn_env.size} env var(s) for agent #{agent_name}" unless spawn_env.empty?
|
|
551
|
+
|
|
552
|
+
pid = spawn(spawn_env, *cmd,
|
|
553
|
+
chdir: project["repo_path"],
|
|
554
|
+
in: prompt_file,
|
|
555
|
+
out: [log_file, "w"],
|
|
556
|
+
err: %i[child out])
|
|
557
|
+
|
|
558
|
+
Thread.new do
|
|
559
|
+
Process.wait(pid)
|
|
560
|
+
handle_cron_completion(job, project, agent_name, agent_config_name, log_file, prompt_data[:response_file], prompt_data[:meta_file])
|
|
561
|
+
rescue StandardError => e
|
|
562
|
+
LOG.error "[Cron] Job #{job[:id]} failed: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Write cron prompt to a temp file, return path.
|
|
567
|
+
def write_cron_prompt_file(job, prompt_content, timestamp)
|
|
568
|
+
prompt_dir = File.join(ZILLACORE_DIR, "tmp")
|
|
569
|
+
FileUtils.mkdir_p(prompt_dir)
|
|
570
|
+
prompt_file = File.join(prompt_dir, "prompt-cron-#{job[:id]}-#{timestamp}.md")
|
|
571
|
+
File.write(prompt_file, prompt_content)
|
|
572
|
+
prompt_file
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Build the CLI command array for a cron agent invocation.
|
|
576
|
+
def build_cron_agent_cmd(job, project)
|
|
577
|
+
agent_config_name = job[:agent].downcase.gsub(/[^a-z0-9-]/, "-")
|
|
578
|
+
resolved = resolve_project_cli_config(project)
|
|
579
|
+
cmd = [resolved["agent_cli"]]
|
|
580
|
+
cmd.push("--agent", agent_config_name)
|
|
581
|
+
cmd.concat(resolved["agent_cli_args"].split)
|
|
582
|
+
add_trust_tools!(cmd, resolved["agent_cli_args"])
|
|
583
|
+
cmd.push(resolved["agent_model_flag"], job[:model]) if resolved["agent_model_flag"]&.length&.positive? && job[:model]
|
|
584
|
+
cmd.push(resolved["agent_effort_flag"], job[:effort]) if resolved["agent_effort_flag"]&.length&.positive? && job[:effort]
|
|
585
|
+
cmd
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Cron loop — runs every minute, checks all jobs
|
|
589
|
+
def cron_loop
|
|
590
|
+
loop do
|
|
591
|
+
now = Time.now
|
|
592
|
+
|
|
593
|
+
# Calculate sleep time to wake up at the start of the next minute
|
|
594
|
+
seconds_until_next_minute = 60 - now.sec
|
|
595
|
+
sleep seconds_until_next_minute
|
|
596
|
+
|
|
597
|
+
now = Time.now
|
|
598
|
+
|
|
599
|
+
CRON_JOBS_MUTEX.synchronize do
|
|
600
|
+
CRON_JOBS.each_value do |job|
|
|
601
|
+
next unless job[:enabled]
|
|
602
|
+
next unless cron_matches?(job[:parsed], now)
|
|
603
|
+
|
|
604
|
+
# Prevent duplicate runs within the same minute
|
|
605
|
+
if job[:last_run]
|
|
606
|
+
last_run_time = Time.parse(job[:last_run])
|
|
607
|
+
next if (now - last_run_time) < 60
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
execute_cron_job(job)
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
rescue StandardError => e
|
|
614
|
+
LOG.error "[Cron] Loop error: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
615
|
+
sleep 60
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Start cron background thread
|
|
620
|
+
def start_cron_thread
|
|
621
|
+
return if CRON_THREAD[:ref]&.alive?
|
|
622
|
+
|
|
623
|
+
reload_cron_jobs!
|
|
624
|
+
|
|
625
|
+
CRON_THREAD[:ref] = Thread.new do
|
|
626
|
+
LOG.info "[Cron] Starting cron thread..."
|
|
627
|
+
cron_loop
|
|
628
|
+
end
|
|
629
|
+
end
|