robot_lab-am 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
- data/.envrc +6 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.rubocop.yml +1 -0
- data/Archspec.rb +58 -0
- data/CHANGELOG.md +37 -0
- data/CLAUDE.md +99 -0
- data/LICENSE.txt +21 -0
- data/README.md +118 -0
- data/Rakefile +30 -0
- data/bin/am +13 -0
- data/docs/assets/pipeline.svg +100 -0
- data/docs/cli-reference.md +118 -0
- data/docs/daemon.md +104 -0
- data/docs/getting-started.md +112 -0
- data/docs/how-it-works.md +90 -0
- data/docs/index.md +74 -0
- data/docs/privacy.md +73 -0
- data/lib/robot_lab/am/cli.rb +180 -0
- data/lib/robot_lab/am/collector.rb +48 -0
- data/lib/robot_lab/am/config/defaults.yml +18 -0
- data/lib/robot_lab/am/config.rb +58 -0
- data/lib/robot_lab/am/daemon.rb +118 -0
- data/lib/robot_lab/am/daemon_controller.rb +152 -0
- data/lib/robot_lab/am/event.rb +22 -0
- data/lib/robot_lab/am/event_log.rb +40 -0
- data/lib/robot_lab/am/heartbeat.rb +45 -0
- data/lib/robot_lab/am/inferrer.rb +123 -0
- data/lib/robot_lab/am/intent.rb +9 -0
- data/lib/robot_lab/am/intent_writer.rb +36 -0
- data/lib/robot_lab/am/launchd.rb +108 -0
- data/lib/robot_lab/am/pid_file.rb +49 -0
- data/lib/robot_lab/am/redactor.rb +43 -0
- data/lib/robot_lab/am/version.rb +7 -0
- data/lib/robot_lab/am/watchers/claude_watcher.rb +84 -0
- data/lib/robot_lab/am/watchers/git_watcher.rb +57 -0
- data/lib/robot_lab/am/watchers/terminal_watcher.rb +42 -0
- data/lib/robot_lab/am.rb +40 -0
- data/mkdocs.yml +119 -0
- data/sig/robot_lab/am.rbs +6 -0
- metadata +118 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Am
|
|
5
|
+
# Entry point for the `am` executable.
|
|
6
|
+
#
|
|
7
|
+
# `snapshot` is the one-shot collect + infer + write. `start`/`stop`/
|
|
8
|
+
# `status` manage the continuous daemon (DaemonController), and
|
|
9
|
+
# `install`/`uninstall` manage the launchd agent that supervises it.
|
|
10
|
+
# Factories are injectable so tests can exercise dispatch without forking
|
|
11
|
+
# processes or touching ~/Library.
|
|
12
|
+
class CLI
|
|
13
|
+
COMMANDS = %w[snapshot start stop status install uninstall].freeze
|
|
14
|
+
|
|
15
|
+
def self.run(argv = ARGV)
|
|
16
|
+
new.run(argv)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(controller_factory: nil, launchd_factory: nil)
|
|
20
|
+
@controller_factory = controller_factory
|
|
21
|
+
@launchd_factory = launchd_factory
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# :reek:TooManyStatements -- the flag/guard/dispatch sequence reads
|
|
25
|
+
# best as one method; each statement is a distinct early return.
|
|
26
|
+
def run(argv)
|
|
27
|
+
command = argv.first
|
|
28
|
+
return puts("am #{VERSION}") if %w[--version -v].include?(command)
|
|
29
|
+
return puts(usage) if command.nil? || %w[--help -h].include?(command)
|
|
30
|
+
|
|
31
|
+
unless COMMANDS.include?(command)
|
|
32
|
+
warn "Unknown command: #{command.inspect}"
|
|
33
|
+
warn usage
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
dispatch(command, parse_options(argv[1..]))
|
|
38
|
+
rescue Error => e
|
|
39
|
+
warn e.message
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# :reek:ControlParameter :reek:DuplicateMethodCall -- a dispatcher is
|
|
46
|
+
# controlled by its command by definition, and only one case branch
|
|
47
|
+
# (hence one controller build) ever runs per invocation.
|
|
48
|
+
def dispatch(command, options)
|
|
49
|
+
case command
|
|
50
|
+
when "snapshot" then run_snapshot(options)
|
|
51
|
+
when "start" then puts controller(options).start(foreground: options[:foreground])
|
|
52
|
+
when "stop" then puts controller(options).stop
|
|
53
|
+
when "status" then puts controller(options).status
|
|
54
|
+
when "install" then run_install(options)
|
|
55
|
+
when "uninstall" then run_uninstall(options)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def controller_factory
|
|
60
|
+
@controller_factory ||= ->(options) { DaemonController.new(**options.slice(:repo, :interval, :debounce)) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def launchd_factory
|
|
64
|
+
@launchd_factory ||= ->(options) { Launchd.new(**options.slice(:repo, :interval, :debounce)) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def controller(options)
|
|
68
|
+
controller_factory.call(options)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def config
|
|
72
|
+
@config ||= Am.config
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run_snapshot(options)
|
|
76
|
+
repo = options[:repo]
|
|
77
|
+
event_log = EventLog.new(File.join(repo, ".robot_lab_am", "events.jsonl"))
|
|
78
|
+
new_events = Collector.new(repo: repo, event_log: event_log, config: config).collect_new
|
|
79
|
+
|
|
80
|
+
intent = Inferrer.new(config: config).infer(event_log.all.last(config.inference_window), repo: repo)
|
|
81
|
+
path = IntentWriter.new(repo: repo).write(intent)
|
|
82
|
+
|
|
83
|
+
report_snapshot(repo, new_events, path)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def report_snapshot(repo, new_events, path)
|
|
87
|
+
puts "Collected #{new_events.size} new event(s) from #{repo}"
|
|
88
|
+
puts "Wrote #{path}"
|
|
89
|
+
puts ""
|
|
90
|
+
puts File.read(path)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run_install(options)
|
|
94
|
+
launchd = launchd_factory.call(options)
|
|
95
|
+
path = launchd.install
|
|
96
|
+
puts <<~MSG
|
|
97
|
+
Wrote #{path}
|
|
98
|
+
Load it now (and on every login) with:
|
|
99
|
+
launchctl bootstrap gui/#{Process.uid} #{path}
|
|
100
|
+
Unload it with:
|
|
101
|
+
launchctl bootout gui/#{Process.uid}/#{launchd.label}
|
|
102
|
+
MSG
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run_uninstall(options)
|
|
106
|
+
launchd = launchd_factory.call(options)
|
|
107
|
+
path = launchd.uninstall
|
|
108
|
+
puts <<~MSG
|
|
109
|
+
Removed #{path}
|
|
110
|
+
If the agent was loaded, unload it with:
|
|
111
|
+
launchctl bootout gui/#{Process.uid}/#{launchd.label}
|
|
112
|
+
MSG
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# :reek:FeatureEnvy :reek:TooManyStatements -- an option parser's whole
|
|
116
|
+
# job is filling the options hash, one statement per flag.
|
|
117
|
+
# interval/debounce stay nil unless flagged — downstream they fall
|
|
118
|
+
# through to Am::Config (user config file / RLAM_* env vars).
|
|
119
|
+
def parse_options(args)
|
|
120
|
+
options = { repo: Dir.pwd, foreground: false, interval: nil, debounce: nil }
|
|
121
|
+
args = args.dup
|
|
122
|
+
until args.empty?
|
|
123
|
+
arg = args.shift
|
|
124
|
+
case arg
|
|
125
|
+
when "--repo" then options[:repo] = required_value(args, arg)
|
|
126
|
+
when "--interval" then options[:interval] = integer_value(args, arg)
|
|
127
|
+
when "--debounce" then options[:debounce] = integer_value(args, arg)
|
|
128
|
+
when "--foreground" then options[:foreground] = true
|
|
129
|
+
else raise Error, "Unknown option: #{arg.inspect}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
options[:repo] = File.expand_path(options[:repo])
|
|
133
|
+
options
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# :reek:FeatureEnvy -- validating the shifted value is this method's
|
|
137
|
+
# entire job.
|
|
138
|
+
def required_value(args, flag)
|
|
139
|
+
value = args.shift
|
|
140
|
+
raise Error, "#{flag} requires a value" if value.nil? || value.start_with?("--")
|
|
141
|
+
|
|
142
|
+
value
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def integer_value(args, flag)
|
|
146
|
+
Integer(required_value(args, flag))
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
raise Error, "#{flag} requires an integer value"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def usage
|
|
152
|
+
<<~USAGE
|
|
153
|
+
Usage: am COMMAND [options]
|
|
154
|
+
|
|
155
|
+
Commands:
|
|
156
|
+
snapshot One-shot: collect new git/terminal/Claude Code activity, infer the
|
|
157
|
+
current goal, and write .robot_lab_am/current_intent.md
|
|
158
|
+
start Start the activity-monitor daemon for the repo
|
|
159
|
+
(--foreground to run in this terminal instead of detaching)
|
|
160
|
+
stop Stop the running daemon
|
|
161
|
+
status Show whether the daemon is running, with heartbeat detail
|
|
162
|
+
install Write a launchd agent plist so the daemon runs at login
|
|
163
|
+
uninstall Remove the launchd agent plist
|
|
164
|
+
|
|
165
|
+
Options:
|
|
166
|
+
--repo PATH Repo to watch (default: current directory)
|
|
167
|
+
--interval N Seconds between daemon polls (currently: #{config.interval})
|
|
168
|
+
--debounce N Minimum seconds between inference runs (currently: #{config.debounce})
|
|
169
|
+
--foreground With start: run the daemon without detaching
|
|
170
|
+
-h, --help Show this help
|
|
171
|
+
-v, --version Print version and exit
|
|
172
|
+
|
|
173
|
+
Configuration cascade (lowest to highest precedence): bundled
|
|
174
|
+
defaults -> ~/.config/robot_lab_am/robot_lab_am.yml ->
|
|
175
|
+
RLAM_* env vars -> the flags above.
|
|
176
|
+
USAGE
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Am
|
|
5
|
+
# One collection pass over all three watchers: redacts each event, drops
|
|
6
|
+
# anything already in the event log (by Event#fingerprint), and appends
|
|
7
|
+
# what's genuinely new. Both the one-shot `am snapshot` and every daemon
|
|
8
|
+
# tick go through here, so repeated runs never duplicate log lines.
|
|
9
|
+
class Collector
|
|
10
|
+
def initialize(repo:, event_log:, watchers: nil, config: nil)
|
|
11
|
+
@repo = repo
|
|
12
|
+
@event_log = event_log
|
|
13
|
+
@watchers = watchers
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the new events, already redacted and appended to the log.
|
|
18
|
+
def collect_new
|
|
19
|
+
fresh = watchers.flat_map(&:events)
|
|
20
|
+
.map { |event| Redactor.redact_event(event) }
|
|
21
|
+
.reject { |event| seen.include?(event.fingerprint) }
|
|
22
|
+
fresh.each do |event|
|
|
23
|
+
@event_log.append(event)
|
|
24
|
+
seen << event.fingerprint
|
|
25
|
+
end
|
|
26
|
+
fresh
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Seeded once from the existing log so a restart doesn't re-append
|
|
32
|
+
# history; maintained incrementally after that.
|
|
33
|
+
def seen
|
|
34
|
+
@seen ||= @event_log.all.to_set(&:fingerprint)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def config = @config ||= Am.config
|
|
38
|
+
|
|
39
|
+
def watchers
|
|
40
|
+
@watchers ||= [
|
|
41
|
+
Watchers::GitWatcher.new(repo: @repo),
|
|
42
|
+
Watchers::ClaudeWatcher.new(repo: @repo),
|
|
43
|
+
Watchers::TerminalWatcher.new(repo: @repo, log_path: config.terminal_log)
|
|
44
|
+
]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
defaults:
|
|
2
|
+
provider: openai # RubyLLM provider for inference
|
|
3
|
+
model: qwen/qwen3.8-27b # local model served by LM Studio
|
|
4
|
+
api_base: http://localhost:1234/v1 # OpenAI-compatible local server
|
|
5
|
+
api_key: null # null = provider env var, or none (local)
|
|
6
|
+
interval: 15 # seconds between daemon watcher polls
|
|
7
|
+
debounce: 300 # minimum seconds between inference runs
|
|
8
|
+
inference_window: 100 # most recent events sent to the model
|
|
9
|
+
terminal_log: ~/.activity_monitor/terminal_activity.log # preexec command log
|
|
10
|
+
|
|
11
|
+
development:
|
|
12
|
+
{}
|
|
13
|
+
|
|
14
|
+
test:
|
|
15
|
+
{}
|
|
16
|
+
|
|
17
|
+
production:
|
|
18
|
+
{}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "myway_config"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Am
|
|
7
|
+
# Configuration for the activity monitor, following the same
|
|
8
|
+
# MywayConfig::Base pattern as robot_lab-to's Config.
|
|
9
|
+
#
|
|
10
|
+
# Sources (lowest to highest precedence):
|
|
11
|
+
# 1. Bundled defaults (config/defaults.yml)
|
|
12
|
+
# 2. User config file (~/.config/robot_lab_am/robot_lab_am.yml,
|
|
13
|
+
# or $XDG_CONFIG_HOME/robot_lab_am/robot_lab_am.yml)
|
|
14
|
+
# 3. Environment variables (RLAM_*)
|
|
15
|
+
# 4. Constructor keyword arguments (CLI flag overrides)
|
|
16
|
+
#
|
|
17
|
+
# :reek:InstanceVariableAssumption -- the defaults.yml-backed ivars ARE
|
|
18
|
+
# assigned, by `super()` (MywayConfig::Base / Anyway::Config), before any
|
|
19
|
+
# of this class's own code runs. Do NOT pre-nil them in #initialize:
|
|
20
|
+
# that overwrites the real loaded values with nil (robot_lab-to's Config
|
|
21
|
+
# carries the same caveat).
|
|
22
|
+
class Config < MywayConfig::Base
|
|
23
|
+
config_name :robot_lab_am
|
|
24
|
+
env_prefix :rlam
|
|
25
|
+
defaults_path File.expand_path("config/defaults.yml", __dir__)
|
|
26
|
+
auto_configure!
|
|
27
|
+
|
|
28
|
+
# auto_configure! only derives symbol/boolean/section coercions from
|
|
29
|
+
# the YAML value types; the integer settings and the string-to-symbol
|
|
30
|
+
# provider need explicit coercion so RLAM_* env values (always
|
|
31
|
+
# strings) come out typed.
|
|
32
|
+
coerce_types provider: to_symbol, interval: :integer,
|
|
33
|
+
debounce: :integer, inference_window: :integer
|
|
34
|
+
|
|
35
|
+
# Runtime CLI overrides — applied after load. A nil override is
|
|
36
|
+
# ignored (see #initialize), so CLI code can pass flags through
|
|
37
|
+
# unconditionally.
|
|
38
|
+
attr_writer :provider, :model, :api_base, :api_key, :interval, :debounce,
|
|
39
|
+
:inference_window, :terminal_log
|
|
40
|
+
|
|
41
|
+
def initialize(**overrides)
|
|
42
|
+
super()
|
|
43
|
+
overrides.each { |key, value| public_send(:"#{key}=", value) unless value.nil? }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def provider = @provider || super
|
|
47
|
+
def model = @model || super
|
|
48
|
+
def api_base = @api_base || super
|
|
49
|
+
def api_key = @api_key || super
|
|
50
|
+
def interval = @interval || super
|
|
51
|
+
def debounce = @debounce || super
|
|
52
|
+
def inference_window = @inference_window || super
|
|
53
|
+
|
|
54
|
+
# Expanded at read time so "~" works from YAML, env, and overrides alike.
|
|
55
|
+
def terminal_log = File.expand_path(@terminal_log || super)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Am
|
|
7
|
+
# The continuous activity-monitor loop for one repo (ARCHITECTURE.md's
|
|
8
|
+
# Deployment model): poll the watchers, append new events, and re-infer
|
|
9
|
+
# the current intent on a debounced cadence.
|
|
10
|
+
#
|
|
11
|
+
# Cadence (resolves ARCHITECTURE.md open question 1): inference runs only
|
|
12
|
+
# when new events have arrived since the last inference, and at most once
|
|
13
|
+
# per `debounce` seconds — never per keystroke-equivalent event. The first
|
|
14
|
+
# activity after startup infers immediately.
|
|
15
|
+
#
|
|
16
|
+
# Every collaborator is injectable so #tick is testable in isolation with
|
|
17
|
+
# no LLM, no signals, and no sleeping. Tunables (interval, debounce,
|
|
18
|
+
# inference window) default from Am::Config when not passed explicitly.
|
|
19
|
+
class Daemon
|
|
20
|
+
# :reek:LongParameterList -- one keyword per collaborator is the price
|
|
21
|
+
# of #tick being testable in isolation; defaults resolve lazily below.
|
|
22
|
+
def initialize(repo:, config: nil, interval: nil, debounce: nil,
|
|
23
|
+
event_log: nil, collector: nil, inferrer: nil, intent_writer: nil,
|
|
24
|
+
pid_file: nil, heartbeat: nil)
|
|
25
|
+
@repo = repo
|
|
26
|
+
@config = config
|
|
27
|
+
@interval = interval
|
|
28
|
+
@debounce = debounce
|
|
29
|
+
@event_log = event_log
|
|
30
|
+
@collector = collector
|
|
31
|
+
@inferrer = inferrer
|
|
32
|
+
@intent_writer = intent_writer
|
|
33
|
+
@pid_file = pid_file
|
|
34
|
+
@heartbeat = heartbeat
|
|
35
|
+
@stop = false
|
|
36
|
+
@dirty = false
|
|
37
|
+
@events_total = 0
|
|
38
|
+
@last_inference_at = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run
|
|
42
|
+
pid_file.write
|
|
43
|
+
trap_signals
|
|
44
|
+
until @stop
|
|
45
|
+
tick
|
|
46
|
+
wait
|
|
47
|
+
end
|
|
48
|
+
ensure
|
|
49
|
+
pid_file.delete
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stop!
|
|
53
|
+
@stop = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One poll cycle. Public so tests (and future callers) can drive the
|
|
57
|
+
# loop without running it.
|
|
58
|
+
def tick(now: Time.now)
|
|
59
|
+
new_events = collector.collect_new
|
|
60
|
+
@events_total += new_events.size
|
|
61
|
+
@dirty = true unless new_events.empty?
|
|
62
|
+
beat # before inference — a slow LLM call must not make a live daemon look dead
|
|
63
|
+
return unless infer_due?(now)
|
|
64
|
+
|
|
65
|
+
infer(now)
|
|
66
|
+
beat
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def state_path(file)
|
|
72
|
+
File.join(@repo, ".robot_lab_am", file)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def config = @config ||= Am.config
|
|
76
|
+
def interval = @interval || config.interval
|
|
77
|
+
def debounce = @debounce || config.debounce
|
|
78
|
+
|
|
79
|
+
def event_log = @event_log ||= EventLog.new(state_path("events.jsonl"))
|
|
80
|
+
def collector = @collector ||= Collector.new(repo: @repo, event_log: event_log, config: config)
|
|
81
|
+
def inferrer = @inferrer ||= Inferrer.new(config: config)
|
|
82
|
+
def intent_writer = @intent_writer ||= IntentWriter.new(repo: @repo)
|
|
83
|
+
def pid_file = @pid_file ||= PidFile.new(state_path("daemon.pid"))
|
|
84
|
+
def heartbeat = @heartbeat ||= Heartbeat.new(state_path("heartbeat.json"))
|
|
85
|
+
|
|
86
|
+
def beat
|
|
87
|
+
heartbeat.beat(pid: Process.pid, events_total: @events_total,
|
|
88
|
+
last_inference_at: @last_inference_at&.utc&.iso8601)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def infer_due?(now)
|
|
92
|
+
@dirty && (@last_inference_at.nil? || now - @last_inference_at >= debounce)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A failed inference (LLM server down, malformed response) must not kill
|
|
96
|
+
# the daemon; leaving @dirty set retries it once the next debounce
|
|
97
|
+
# window opens, and stamping @last_inference_at anyway is what spaces
|
|
98
|
+
# those retries out.
|
|
99
|
+
def infer(now)
|
|
100
|
+
window = event_log.all.last(config.inference_window)
|
|
101
|
+
intent_writer.write(inferrer.infer(window, repo: @repo))
|
|
102
|
+
@dirty = false
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
warn "[robot_lab-am] inference failed: #{e.class}: #{e.message}"
|
|
105
|
+
ensure
|
|
106
|
+
@last_inference_at = now
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def wait
|
|
110
|
+
sleep(interval) unless @stop
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def trap_signals
|
|
114
|
+
%w[TERM INT].each { |signal| Signal.trap(signal) { @stop = true } }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Am
|
|
7
|
+
# Process management behind `am start|stop|status`: forks and detaches the
|
|
8
|
+
# Daemon, tears it down with SIGTERM, and reports liveness from the pid
|
|
9
|
+
# file plus the heartbeat. Methods return a human-readable message on
|
|
10
|
+
# success and raise Am::Error on failure — the CLI turns those into
|
|
11
|
+
# stdout/exit codes.
|
|
12
|
+
#
|
|
13
|
+
# :reek:RepeatedConditional -- daemon liveness (@pid_file.alive?) is
|
|
14
|
+
# deliberately re-checked at each lifecycle boundary (start guard, stop,
|
|
15
|
+
# status); caching it would defeat the point.
|
|
16
|
+
class DaemonController
|
|
17
|
+
START_TIMEOUT_SECONDS = 10
|
|
18
|
+
STOP_TIMEOUT_SECONDS = 10
|
|
19
|
+
|
|
20
|
+
def initialize(repo:, interval: nil, debounce: nil,
|
|
21
|
+
daemon_factory: nil, spawner: nil)
|
|
22
|
+
@repo = repo
|
|
23
|
+
@interval = interval
|
|
24
|
+
@debounce = debounce
|
|
25
|
+
@pid_file = PidFile.new(File.join(state_dir, "daemon.pid"))
|
|
26
|
+
@heartbeat = Heartbeat.new(File.join(state_dir, "heartbeat.json"))
|
|
27
|
+
@daemon_factory = daemon_factory
|
|
28
|
+
@spawner = spawner
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# :reek:BooleanParameter :reek:ControlParameter -- mirrors the CLI's
|
|
32
|
+
# --foreground flag; both paths share the already-running guard.
|
|
33
|
+
def start(foreground: false)
|
|
34
|
+
raise Error, "daemon already running for #{@repo} (pid #{@pid_file.read})" if @pid_file.alive?
|
|
35
|
+
|
|
36
|
+
@pid_file.delete
|
|
37
|
+
return run_foreground if foreground
|
|
38
|
+
|
|
39
|
+
run_background
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stop
|
|
43
|
+
pid = @pid_file.read
|
|
44
|
+
raise Error, "daemon not running for #{@repo}" unless pid
|
|
45
|
+
|
|
46
|
+
unless @pid_file.alive?
|
|
47
|
+
@pid_file.delete
|
|
48
|
+
raise Error, "daemon not running for #{@repo} (removed stale pid file for pid #{pid})"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Process.kill("TERM", pid)
|
|
52
|
+
wait_until(STOP_TIMEOUT_SECONDS, "daemon (pid #{pid}) did not exit within #{STOP_TIMEOUT_SECONDS}s") do
|
|
53
|
+
!@pid_file.alive?
|
|
54
|
+
end
|
|
55
|
+
"stopped daemon for #{@repo} (pid #{pid})"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def status
|
|
59
|
+
unless @pid_file.alive?
|
|
60
|
+
suffix = @pid_file.exist? ? " (stale pid file: #{@pid_file.read})" : ""
|
|
61
|
+
raise Error, "daemon not running for #{@repo}#{suffix}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
["daemon running for #{@repo} (pid #{@pid_file.read})", *heartbeat_lines].join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def daemon_factory = @daemon_factory ||= method(:build_daemon)
|
|
70
|
+
def spawner = @spawner ||= method(:spawn_daemon)
|
|
71
|
+
|
|
72
|
+
def state_dir
|
|
73
|
+
File.join(@repo, ".robot_lab_am")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def log_path
|
|
77
|
+
File.join(state_dir, "daemon.log")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# nil interval/debounce fall through to the Daemon's Am::Config lookup.
|
|
81
|
+
def build_daemon
|
|
82
|
+
Daemon.new(repo: @repo, interval: @interval, debounce: @debounce)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def run_foreground
|
|
86
|
+
daemon_factory.call.run
|
|
87
|
+
"daemon for #{@repo} exited"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run_background
|
|
91
|
+
child = spawner.call
|
|
92
|
+
Process.detach(child)
|
|
93
|
+
wait_until(START_TIMEOUT_SECONDS, "daemon did not start within #{START_TIMEOUT_SECONDS}s — see #{log_path}") do
|
|
94
|
+
@pid_file.alive?
|
|
95
|
+
end
|
|
96
|
+
"started daemon for #{@repo} (pid #{@pid_file.read}), logging to #{log_path}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The double fork: the forked child detaches from the terminal via
|
|
100
|
+
# Process.daemon, then runs the loop. exit! (not exit) so the child
|
|
101
|
+
# never runs at_exit hooks inherited from the parent process.
|
|
102
|
+
def spawn_daemon
|
|
103
|
+
fork do
|
|
104
|
+
Process.daemon(true)
|
|
105
|
+
redirect_output
|
|
106
|
+
exit!(run_daemon_safely)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Exit status for the daemonized child — 0 for a clean stop, 1 for a
|
|
111
|
+
# crash, with the crash recorded in the log file.
|
|
112
|
+
# :reek:FeatureEnvy -- formatting the caught exception for the log
|
|
113
|
+
# inherently reads `e` more than self.
|
|
114
|
+
def run_daemon_safely
|
|
115
|
+
daemon_factory.call.run
|
|
116
|
+
0
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
warn "[robot_lab-am] daemon crashed: #{e.class}: #{e.message}"
|
|
119
|
+
warn e.backtrace.join("\n") if e.backtrace
|
|
120
|
+
1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def redirect_output
|
|
124
|
+
FileUtils.mkdir_p(File.dirname(log_path))
|
|
125
|
+
$stdout.reopen(log_path, "a")
|
|
126
|
+
$stderr.reopen(log_path, "a")
|
|
127
|
+
$stdout.sync = true
|
|
128
|
+
$stderr.sync = true
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def heartbeat_lines
|
|
132
|
+
data = @heartbeat.read
|
|
133
|
+
return [] unless data
|
|
134
|
+
|
|
135
|
+
[
|
|
136
|
+
" heartbeat: #{@heartbeat.age}s ago",
|
|
137
|
+
" events logged since start: #{data['events_total']}",
|
|
138
|
+
" last inference: #{data['last_inference_at'] || 'never'}"
|
|
139
|
+
]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def wait_until(timeout, failure_message)
|
|
143
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
144
|
+
until yield
|
|
145
|
+
raise Error, failure_message if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
146
|
+
|
|
147
|
+
sleep 0.1
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Am
|
|
5
|
+
# One normalized activity signal, per the shape decided in
|
|
6
|
+
# ARCHITECTURE.md's "Ingestion & normalization" section.
|
|
7
|
+
Event = Data.define(:timestamp, :repo, :source, :kind, :summary) do
|
|
8
|
+
def to_h_json
|
|
9
|
+
{ timestamp: timestamp, repo: repo, source: source, kind: kind, summary: summary }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Identity for read-side dedupe (Collector). "wip" events are stamped
|
|
13
|
+
# with collection time, so two reads of the same dirty tree never share
|
|
14
|
+
# a timestamp — their identity is the summary alone.
|
|
15
|
+
def fingerprint
|
|
16
|
+
parts = [repo, source, kind, summary]
|
|
17
|
+
parts << timestamp unless kind == "wip"
|
|
18
|
+
parts.join("\x1F")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
module Am
|
|
8
|
+
# Append-only JSONL event store — one Event per line. Chosen over SQLite
|
|
9
|
+
# per ARCHITECTURE.md's Storage section: the only consumer is an LLM
|
|
10
|
+
# summarization pass over a bounded recent window, not ad hoc queries.
|
|
11
|
+
class EventLog
|
|
12
|
+
def initialize(path)
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def append(event)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
18
|
+
File.open(@path, "a") { |f| f.puts(event.to_h_json.to_json) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def append_all(events)
|
|
22
|
+
events.each { |event| append(event) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def all
|
|
26
|
+
return [] unless File.exist?(@path)
|
|
27
|
+
|
|
28
|
+
File.readlines(@path).filter_map { |line| parse_line(line) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def parse_line(line)
|
|
34
|
+
Event.new(**JSON.parse(line, symbolize_names: true))
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module RobotLab
|
|
8
|
+
module Am
|
|
9
|
+
# The daemon's liveness record (.robot_lab_am/heartbeat.json), rewritten
|
|
10
|
+
# every tick — the "heartbeat row/line" ARCHITECTURE.md's Deployment model
|
|
11
|
+
# section calls for, so `am status` can tell a live daemon from a stale
|
|
12
|
+
# pid file left by a crash.
|
|
13
|
+
class Heartbeat
|
|
14
|
+
def initialize(path)
|
|
15
|
+
@path = path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def beat(pid:, events_total:, last_inference_at:)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
20
|
+
File.write(@path, JSON.generate(
|
|
21
|
+
pid: pid,
|
|
22
|
+
updated_at: Time.now.utc.iso8601,
|
|
23
|
+
events_total: events_total,
|
|
24
|
+
last_inference_at: last_inference_at
|
|
25
|
+
))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read
|
|
29
|
+
return nil unless File.exist?(@path)
|
|
30
|
+
|
|
31
|
+
JSON.parse(File.read(@path))
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Seconds since the last beat, or nil when there has been none.
|
|
37
|
+
def age
|
|
38
|
+
updated_at = read&.fetch("updated_at", nil)
|
|
39
|
+
return nil unless updated_at
|
|
40
|
+
|
|
41
|
+
(Time.now.utc - Time.parse(updated_at)).round
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|