pocketrb 0.1.0
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
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- metadata +327 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "job"
|
|
7
|
+
|
|
8
|
+
module Pocketrb
|
|
9
|
+
module Cron
|
|
10
|
+
# Manages scheduled jobs with persistence and execution
|
|
11
|
+
class Service
|
|
12
|
+
attr_reader :jobs
|
|
13
|
+
|
|
14
|
+
def initialize(store_path:, on_job:)
|
|
15
|
+
@store_path = Pathname.new(store_path)
|
|
16
|
+
@on_job = on_job
|
|
17
|
+
@jobs = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@running = false
|
|
20
|
+
@timer_thread = nil
|
|
21
|
+
|
|
22
|
+
ensure_store_dir!
|
|
23
|
+
load_jobs!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Start the cron service
|
|
27
|
+
def start
|
|
28
|
+
return if @running
|
|
29
|
+
|
|
30
|
+
@running = true
|
|
31
|
+
compute_next_runs!
|
|
32
|
+
arm_timer!
|
|
33
|
+
|
|
34
|
+
Pocketrb.logger.info("Cron service started with #{@jobs.size} jobs")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Stop the cron service
|
|
38
|
+
def stop
|
|
39
|
+
@running = false
|
|
40
|
+
@timer_thread&.kill
|
|
41
|
+
@timer_thread = nil
|
|
42
|
+
|
|
43
|
+
Pocketrb.logger.info("Cron service stopped")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# List all jobs
|
|
47
|
+
# @param include_disabled [Boolean] Include disabled jobs
|
|
48
|
+
# @return [Array<Job>]
|
|
49
|
+
def list_jobs(include_disabled: false)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
jobs = @jobs.values
|
|
52
|
+
jobs = jobs.select(&:enabled) unless include_disabled
|
|
53
|
+
jobs.sort_by { |j| j.state.next_run_at_ms || Float::INFINITY }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Add a new job
|
|
58
|
+
# @param name [String] Job name
|
|
59
|
+
# @param schedule [Schedule] Schedule configuration
|
|
60
|
+
# @param message [String] Message to process
|
|
61
|
+
# @param deliver [Boolean] Deliver to channel vs process as agent task
|
|
62
|
+
# @param channel [String, nil] Target channel
|
|
63
|
+
# @param to [String, nil] Target chat ID
|
|
64
|
+
# @return [Job] Created job
|
|
65
|
+
def add_job(name:, schedule:, message:, deliver: false, channel: nil, to: nil)
|
|
66
|
+
job_id = SecureRandom.hex(8)
|
|
67
|
+
|
|
68
|
+
payload = Payload.new(
|
|
69
|
+
message: message,
|
|
70
|
+
deliver: deliver,
|
|
71
|
+
channel: channel,
|
|
72
|
+
to: to
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
job = Job.new(
|
|
76
|
+
id: job_id,
|
|
77
|
+
name: name,
|
|
78
|
+
schedule: schedule,
|
|
79
|
+
payload: payload,
|
|
80
|
+
delete_after_run: schedule.one_time?
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
job = compute_next_run(job)
|
|
84
|
+
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
@jobs[job_id] = job
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
save_jobs!
|
|
90
|
+
arm_timer!
|
|
91
|
+
|
|
92
|
+
Pocketrb.logger.info("Added cron job '#{name}' (ID: #{job_id})")
|
|
93
|
+
job
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Add a job with interval schedule
|
|
97
|
+
# @param name [String] Job name
|
|
98
|
+
# @param every [Integer] Interval in seconds
|
|
99
|
+
# @param message [String] Message to process
|
|
100
|
+
# @param deliver [Boolean] Deliver to channel
|
|
101
|
+
# @param channel [String, nil] Target channel
|
|
102
|
+
# @param to [String, nil] Target chat ID
|
|
103
|
+
# @return [Job]
|
|
104
|
+
def add_interval_job(name:, every:, message:, deliver: false, channel: nil, to: nil)
|
|
105
|
+
schedule = Pocketrb::Cron::Schedule.new(kind: :every, every_ms: every * 1000)
|
|
106
|
+
add_job(name: name, schedule: schedule, message: message, deliver: deliver, channel: channel, to: to)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add a job with cron expression
|
|
110
|
+
# @param name [String] Job name
|
|
111
|
+
# @param cron [String] Cron expression
|
|
112
|
+
# @param message [String] Message to process
|
|
113
|
+
# @param tz [String, nil] Timezone
|
|
114
|
+
# @param deliver [Boolean] Deliver to channel
|
|
115
|
+
# @param channel [String, nil] Target channel
|
|
116
|
+
# @param to [String, nil] Target chat ID
|
|
117
|
+
# @return [Job]
|
|
118
|
+
def add_cron_job(name:, cron:, message:, tz: nil, deliver: false, channel: nil, to: nil)
|
|
119
|
+
schedule = Pocketrb::Cron::Schedule.new(kind: :cron, expr: cron, tz: tz)
|
|
120
|
+
add_job(name: name, schedule: schedule, message: message, deliver: deliver, channel: channel, to: to)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Add a one-time job
|
|
124
|
+
# @param name [String] Job name
|
|
125
|
+
# @param at [Time, Integer] Execution time (Time or Unix timestamp)
|
|
126
|
+
# @param message [String] Message to process
|
|
127
|
+
# @param deliver [Boolean] Deliver to channel
|
|
128
|
+
# @param channel [String, nil] Target channel
|
|
129
|
+
# @param to [String, nil] Target chat ID
|
|
130
|
+
# @return [Job]
|
|
131
|
+
def add_one_time_job(name:, at:, message:, deliver: false, channel: nil, to: nil)
|
|
132
|
+
at_ms = at.is_a?(Time) ? (at.to_f * 1000).to_i : at * 1000
|
|
133
|
+
schedule = Pocketrb::Cron::Schedule.new(kind: :at, at_ms: at_ms)
|
|
134
|
+
add_job(name: name, schedule: schedule, message: message, deliver: deliver, channel: channel, to: to)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Remove a job
|
|
138
|
+
# @param job_id [String] Job ID
|
|
139
|
+
# @return [Boolean] Whether job was removed
|
|
140
|
+
def remove_job(job_id)
|
|
141
|
+
removed = @mutex.synchronize do
|
|
142
|
+
@jobs.delete(job_id)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if removed
|
|
146
|
+
save_jobs!
|
|
147
|
+
arm_timer!
|
|
148
|
+
Pocketrb.logger.info("Removed cron job #{job_id}")
|
|
149
|
+
true
|
|
150
|
+
else
|
|
151
|
+
false
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Enable or disable a job
|
|
156
|
+
# @param job_id [String] Job ID
|
|
157
|
+
# @param enabled [Boolean] Enabled state
|
|
158
|
+
# @return [Job, nil] Updated job
|
|
159
|
+
def enable_job(job_id, enabled: true)
|
|
160
|
+
job = @mutex.synchronize do
|
|
161
|
+
current = @jobs[job_id]
|
|
162
|
+
return nil unless current
|
|
163
|
+
|
|
164
|
+
updated = Job.new(
|
|
165
|
+
id: current.id,
|
|
166
|
+
name: current.name,
|
|
167
|
+
enabled: enabled,
|
|
168
|
+
schedule: current.schedule,
|
|
169
|
+
payload: current.payload,
|
|
170
|
+
state: current.state,
|
|
171
|
+
created_at_ms: current.created_at_ms,
|
|
172
|
+
updated_at_ms: (Time.now.to_f * 1000).to_i,
|
|
173
|
+
delete_after_run: current.delete_after_run
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
@jobs[job_id] = updated
|
|
177
|
+
updated
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
save_jobs!
|
|
181
|
+
arm_timer!
|
|
182
|
+
job
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Run a job manually (force execution)
|
|
186
|
+
# @param job_id [String] Job ID
|
|
187
|
+
# @param force [Boolean] Run even if disabled
|
|
188
|
+
# @return [Boolean] Whether job was executed
|
|
189
|
+
def run_job(job_id, force: false)
|
|
190
|
+
job = @mutex.synchronize { @jobs[job_id] }
|
|
191
|
+
return false unless job
|
|
192
|
+
return false unless force || job.enabled
|
|
193
|
+
|
|
194
|
+
execute_job(job)
|
|
195
|
+
true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Get a job by ID
|
|
199
|
+
# @param job_id [String] Job ID
|
|
200
|
+
# @return [Job, nil]
|
|
201
|
+
def get_job(job_id)
|
|
202
|
+
@mutex.synchronize { @jobs[job_id] }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def ensure_store_dir!
|
|
208
|
+
FileUtils.mkdir_p(@store_path.dirname)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def load_jobs!
|
|
212
|
+
return unless @store_path.exist?
|
|
213
|
+
|
|
214
|
+
data = JSON.parse(File.read(@store_path))
|
|
215
|
+
@jobs = data.transform_values { |h| Job.from_h(h) }
|
|
216
|
+
Pocketrb.logger.debug("Loaded #{@jobs.size} cron jobs")
|
|
217
|
+
rescue JSON::ParserError => e
|
|
218
|
+
Pocketrb.logger.warn("Failed to parse cron jobs: #{e.message}")
|
|
219
|
+
@jobs = {}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def save_jobs!
|
|
223
|
+
@mutex.synchronize do
|
|
224
|
+
data = @jobs.transform_values(&:to_h)
|
|
225
|
+
File.write(@store_path, JSON.pretty_generate(data))
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def compute_next_runs!
|
|
230
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
231
|
+
|
|
232
|
+
@mutex.synchronize do
|
|
233
|
+
@jobs.each do |id, job|
|
|
234
|
+
next unless job.enabled
|
|
235
|
+
next if job.state.next_run_at_ms && job.state.next_run_at_ms > now_ms
|
|
236
|
+
|
|
237
|
+
@jobs[id] = compute_next_run(job)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
save_jobs!
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def compute_next_run(job)
|
|
245
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
246
|
+
next_ms = case job.schedule.kind
|
|
247
|
+
when :at
|
|
248
|
+
job.schedule.at_ms
|
|
249
|
+
when :every
|
|
250
|
+
base = job.state.last_run_at_ms || now_ms
|
|
251
|
+
base + job.schedule.every_ms
|
|
252
|
+
when :cron
|
|
253
|
+
compute_cron_next(job.schedule.expr, job.schedule.tz)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
new_state = JobState.new(
|
|
257
|
+
next_run_at_ms: next_ms,
|
|
258
|
+
last_run_at_ms: job.state.last_run_at_ms,
|
|
259
|
+
last_status: job.state.last_status,
|
|
260
|
+
last_error: job.state.last_error
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
Job.new(
|
|
264
|
+
id: job.id,
|
|
265
|
+
name: job.name,
|
|
266
|
+
enabled: job.enabled,
|
|
267
|
+
schedule: job.schedule,
|
|
268
|
+
payload: job.payload,
|
|
269
|
+
state: new_state,
|
|
270
|
+
created_at_ms: job.created_at_ms,
|
|
271
|
+
updated_at_ms: job.updated_at_ms,
|
|
272
|
+
delete_after_run: job.delete_after_run
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def compute_cron_next(expr, tz = nil)
|
|
277
|
+
# Try to use fugit if available
|
|
278
|
+
|
|
279
|
+
require "fugit"
|
|
280
|
+
cron = Fugit.parse_cron(expr)
|
|
281
|
+
return nil unless cron
|
|
282
|
+
|
|
283
|
+
now = Time.now
|
|
284
|
+
now = now.in_time_zone(tz) if tz && now.respond_to?(:in_time_zone)
|
|
285
|
+
|
|
286
|
+
next_time = cron.next_time(now)
|
|
287
|
+
(next_time.to_f * 1000).to_i
|
|
288
|
+
rescue LoadError
|
|
289
|
+
# Fallback: simple minute-based scheduling without fugit
|
|
290
|
+
Pocketrb.logger.warn("Fugit gem not available, cron expressions may not work correctly")
|
|
291
|
+
((Time.now.to_f * 1000) + 60_000).to_i
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def arm_timer!
|
|
295
|
+
return unless @running
|
|
296
|
+
|
|
297
|
+
@timer_thread&.kill
|
|
298
|
+
@timer_thread = nil
|
|
299
|
+
|
|
300
|
+
# Find earliest next run
|
|
301
|
+
earliest = @mutex.synchronize do
|
|
302
|
+
@jobs.values
|
|
303
|
+
.select(&:enabled)
|
|
304
|
+
.filter_map { |j| j.state.next_run_at_ms }
|
|
305
|
+
.min
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
return unless earliest
|
|
309
|
+
|
|
310
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
311
|
+
delay_ms = [earliest - now_ms, 0].max
|
|
312
|
+
delay_s = delay_ms / 1000.0
|
|
313
|
+
|
|
314
|
+
@timer_thread = Thread.new do
|
|
315
|
+
sleep delay_s
|
|
316
|
+
tick if @running
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
Pocketrb.logger.debug("Cron timer armed for #{delay_s}s")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def tick
|
|
323
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
324
|
+
due_jobs = @mutex.synchronize do
|
|
325
|
+
@jobs.values.select { |j| j.due?(now_ms) }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
due_jobs.each do |job|
|
|
329
|
+
execute_job(job)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
arm_timer!
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def execute_job(job)
|
|
336
|
+
Pocketrb.logger.info("Executing cron job '#{job.name}' (ID: #{job.id})")
|
|
337
|
+
|
|
338
|
+
begin
|
|
339
|
+
@on_job.call(job)
|
|
340
|
+
|
|
341
|
+
update_job_state(job.id, status: "success")
|
|
342
|
+
|
|
343
|
+
if job.delete_after_run
|
|
344
|
+
remove_job(job.id)
|
|
345
|
+
else
|
|
346
|
+
update_job_next_run(job.id)
|
|
347
|
+
end
|
|
348
|
+
rescue StandardError => e
|
|
349
|
+
Pocketrb.logger.error("Cron job #{job.id} failed: #{e.message}")
|
|
350
|
+
update_job_state(job.id, status: "failed", error: e.message)
|
|
351
|
+
update_job_next_run(job.id) unless job.delete_after_run
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def update_job_state(job_id, status:, error: nil)
|
|
356
|
+
@mutex.synchronize do
|
|
357
|
+
job = @jobs[job_id]
|
|
358
|
+
return unless job
|
|
359
|
+
|
|
360
|
+
new_state = JobState.new(
|
|
361
|
+
next_run_at_ms: job.state.next_run_at_ms,
|
|
362
|
+
last_run_at_ms: (Time.now.to_f * 1000).to_i,
|
|
363
|
+
last_status: status,
|
|
364
|
+
last_error: error
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
@jobs[job_id] = Job.new(
|
|
368
|
+
id: job.id,
|
|
369
|
+
name: job.name,
|
|
370
|
+
enabled: job.enabled,
|
|
371
|
+
schedule: job.schedule,
|
|
372
|
+
payload: job.payload,
|
|
373
|
+
state: new_state,
|
|
374
|
+
created_at_ms: job.created_at_ms,
|
|
375
|
+
updated_at_ms: (Time.now.to_f * 1000).to_i,
|
|
376
|
+
delete_after_run: job.delete_after_run
|
|
377
|
+
)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
save_jobs!
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def update_job_next_run(job_id)
|
|
384
|
+
@mutex.synchronize do
|
|
385
|
+
job = @jobs[job_id]
|
|
386
|
+
return unless job
|
|
387
|
+
|
|
388
|
+
@jobs[job_id] = compute_next_run(job)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
save_jobs!
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module Heartbeat
|
|
5
|
+
# Periodic wake-up service that checks HEARTBEAT.md for pending tasks
|
|
6
|
+
class Service
|
|
7
|
+
DEFAULT_INTERVAL = 30 * 60 # 30 minutes
|
|
8
|
+
HEARTBEAT_FILE = "HEARTBEAT.md"
|
|
9
|
+
|
|
10
|
+
HEARTBEAT_PROMPT = <<~PROMPT
|
|
11
|
+
Read HEARTBEAT.md in your workspace. Follow any instructions or tasks listed there.
|
|
12
|
+
If nothing needs attention, reply with: HEARTBEAT_OK
|
|
13
|
+
|
|
14
|
+
Guidelines:
|
|
15
|
+
- Complete any tasks listed in HEARTBEAT.md
|
|
16
|
+
- Mark completed tasks as done (check the checkbox)
|
|
17
|
+
- If a task cannot be completed, explain why
|
|
18
|
+
- Only reply HEARTBEAT_OK if the file is empty or contains no actionable items
|
|
19
|
+
PROMPT
|
|
20
|
+
|
|
21
|
+
attr_reader :interval, :enabled, :last_run_at
|
|
22
|
+
|
|
23
|
+
def initialize(workspace:, on_heartbeat:, interval: DEFAULT_INTERVAL, enabled: true)
|
|
24
|
+
@workspace = Pathname.new(workspace)
|
|
25
|
+
@on_heartbeat = on_heartbeat
|
|
26
|
+
@interval = interval
|
|
27
|
+
@enabled = enabled
|
|
28
|
+
@running = false
|
|
29
|
+
@timer_thread = nil
|
|
30
|
+
@last_run_at = nil
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Start the heartbeat service
|
|
35
|
+
def start
|
|
36
|
+
return unless @enabled
|
|
37
|
+
return if @running
|
|
38
|
+
|
|
39
|
+
@running = true
|
|
40
|
+
arm_timer!
|
|
41
|
+
|
|
42
|
+
Pocketrb.logger.info("Heartbeat service started (interval: #{@interval}s)")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stop the heartbeat service
|
|
46
|
+
def stop
|
|
47
|
+
@running = false
|
|
48
|
+
@timer_thread&.kill
|
|
49
|
+
@timer_thread = nil
|
|
50
|
+
|
|
51
|
+
Pocketrb.logger.info("Heartbeat service stopped")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Update the interval
|
|
55
|
+
# @param seconds [Integer] New interval in seconds
|
|
56
|
+
def set_interval(seconds)
|
|
57
|
+
@mutex.synchronize { @interval = seconds }
|
|
58
|
+
arm_timer! if @running
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Enable or disable the service
|
|
62
|
+
# @param value [Boolean] Enabled state
|
|
63
|
+
def enabled=(value)
|
|
64
|
+
was_enabled = @enabled
|
|
65
|
+
@enabled = value
|
|
66
|
+
|
|
67
|
+
if @enabled && !was_enabled && @running
|
|
68
|
+
arm_timer!
|
|
69
|
+
elsif !@enabled && was_enabled
|
|
70
|
+
@timer_thread&.kill
|
|
71
|
+
@timer_thread = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Force a heartbeat check now
|
|
76
|
+
# @return [String, nil] Response from agent or nil if skipped
|
|
77
|
+
def tick
|
|
78
|
+
@mutex.synchronize { @last_run_at = Time.now }
|
|
79
|
+
|
|
80
|
+
content = read_heartbeat_file
|
|
81
|
+
if empty_heartbeat?(content)
|
|
82
|
+
Pocketrb.logger.debug("Heartbeat: no actionable content, skipping")
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Pocketrb.logger.info("Heartbeat: processing HEARTBEAT.md")
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
response = @on_heartbeat.call(HEARTBEAT_PROMPT)
|
|
90
|
+
|
|
91
|
+
if response&.include?("HEARTBEAT_OK")
|
|
92
|
+
Pocketrb.logger.debug("Heartbeat: agent reports OK")
|
|
93
|
+
else
|
|
94
|
+
Pocketrb.logger.info("Heartbeat: agent processed tasks")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
response
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
Pocketrb.logger.error("Heartbeat failed: #{e.message}")
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get status information
|
|
105
|
+
# @return [Hash] Status info
|
|
106
|
+
def status
|
|
107
|
+
{
|
|
108
|
+
enabled: @enabled,
|
|
109
|
+
running: @running,
|
|
110
|
+
interval: @interval,
|
|
111
|
+
last_run_at: @last_run_at,
|
|
112
|
+
heartbeat_file: heartbeat_file.to_s,
|
|
113
|
+
file_exists: heartbeat_file.exist?,
|
|
114
|
+
next_run_in: next_run_in
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def arm_timer!
|
|
121
|
+
return unless @running && @enabled
|
|
122
|
+
|
|
123
|
+
@timer_thread&.kill
|
|
124
|
+
@timer_thread = nil
|
|
125
|
+
|
|
126
|
+
@timer_thread = Thread.new do
|
|
127
|
+
loop do
|
|
128
|
+
sleep @interval
|
|
129
|
+
tick if @running && @enabled
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def heartbeat_file
|
|
135
|
+
@workspace.join(HEARTBEAT_FILE)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def read_heartbeat_file
|
|
139
|
+
return nil unless heartbeat_file.exist?
|
|
140
|
+
|
|
141
|
+
heartbeat_file.read
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
Pocketrb.logger.warn("Failed to read HEARTBEAT.md: #{e.message}")
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def empty_heartbeat?(content)
|
|
148
|
+
return true if content.nil? || content.strip.empty?
|
|
149
|
+
|
|
150
|
+
# Skip if only contains:
|
|
151
|
+
# - Empty lines
|
|
152
|
+
# - Headers (# ...)
|
|
153
|
+
# - HTML comments (<!-- ... -->)
|
|
154
|
+
# - Unchecked checkboxes that are likely template (- [ ])
|
|
155
|
+
# - Checked checkboxes (- [x])
|
|
156
|
+
content.lines.all? do |line|
|
|
157
|
+
stripped = line.strip
|
|
158
|
+
stripped.empty? ||
|
|
159
|
+
stripped.start_with?("#") ||
|
|
160
|
+
stripped.start_with?("<!--") ||
|
|
161
|
+
stripped.end_with?("-->") ||
|
|
162
|
+
stripped.match?(/^- \[[xX ]\]\s*$/) # Empty checkbox
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def next_run_in
|
|
167
|
+
return nil unless @running && @enabled && @last_run_at
|
|
168
|
+
|
|
169
|
+
elapsed = Time.now - @last_run_at
|
|
170
|
+
remaining = @interval - elapsed
|
|
171
|
+
remaining.positive? ? remaining.to_i : 0
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|