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.
@@ -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