Module: Harnex
- Defined in:
- lib/harnex/cli.rb,
lib/harnex/core.rb,
lib/harnex/config.rb,
lib/harnex/pricing.rb,
lib/harnex/version.rb,
lib/harnex/watcher.rb,
lib/harnex/adapters.rb,
lib/harnex/retention.rb,
lib/harnex/adapters/pi.rb,
lib/harnex/commands/run.rb,
lib/harnex/adapters/base.rb,
lib/harnex/commands/logs.rb,
lib/harnex/commands/pane.rb,
lib/harnex/commands/send.rb,
lib/harnex/commands/stop.rb,
lib/harnex/commands/wait.rb,
lib/harnex/orchestration.rb,
lib/harnex/runtime/inbox.rb,
lib/harnex/adapters/codex.rb,
lib/harnex/commands/guide.rb,
lib/harnex/commands/watch.rb,
lib/harnex/adapters/claude.rb,
lib/harnex/artifact_report.rb,
lib/harnex/commands/doctor.rb,
lib/harnex/commands/events.rb,
lib/harnex/commands/status.rb,
lib/harnex/runtime/message.rb,
lib/harnex/runtime/session.rb,
lib/harnex/terminal_status.rb,
lib/harnex/watcher/inotify.rb,
lib/harnex/watcher/polling.rb,
lib/harnex/adapters/generic.rb,
lib/harnex/commands/history.rb,
lib/harnex/commands/recipes.rb,
lib/harnex/dispatch_history.rb,
lib/harnex/adapters/opencode.rb,
lib/harnex/context_telemetry.rb,
lib/harnex/runtime/api_server.rb,
lib/harnex/commands/agents_guide.rb,
lib/harnex/runtime/session_state.rb,
lib/harnex/commands/orchestration.rb,
lib/harnex/commands/watch_presets.rb,
lib/harnex/codex/app_server/client.rb,
lib/harnex/adapters/codex_appserver.rb,
lib/harnex/commands/artifact_report.rb,
lib/harnex/runtime/file_change_hook.rb
Defined Under Namespace
Modules: Adapters, ArtifactReport, Codex, Config, DispatchHistory, Inotify, Orchestration, Polling, Pricing, Retention, TerminalStatus, WatchPresets, Watcher Classes: AgentsGuide, ApiServer, ArtifactReportCommand, BinaryNotFound, CLI, ContextTelemetry, Doctor, Events, FileChangeHook, Guide, History, Inbox, Logs, Message, OrchestrationCommand, Pane, Recipes, RunWatcher, Runner, Sender, Session, SessionState, Status, Stopper, TerminalWatcher, Waiter, WatchCommand, WatchConfig
Constant Summary collapse
- DEFAULT_HOST =
env_value("HARNEX_HOST", default: "127.0.0.1")
- DEFAULT_BASE_PORT =
Integer(env_value("HARNEX_BASE_PORT", default: "43000"))
- DEFAULT_PORT_SPAN =
Integer(env_value("HARNEX_PORT_SPAN", default: "4000"))
- DEFAULT_ID =
"default"- WATCH_DEBOUNCE_SECONDS =
1.0- GIT_FINGERPRINT_FULL_BYTES =
4 * 1024 * 1024
- GIT_FINGERPRINT_SAMPLE_BYTES =
64 * 1024
- STATE_DIR =
File.(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
- SESSIONS_DIR =
File.join(STATE_DIR, "sessions")
- ID_ADJECTIVES =
%w[ bold blue calm cool dark dry fast gold gray green keen loud mint pale pink red shy slim soft warm ].freeze
- ID_NOUNS =
%w[ ant bat bee cat cod cow cub doe elk fox hen jay kit owl pug ram ray seal wasp yak ].freeze
- VERSION =
"0.10.0"- RELEASE_DATE =
"2026-08-03"
Class Method Summary collapse
- .active_session_ids(repo_root) ⇒ Object
- .active_sessions(repo_root = nil, id: nil, cli: nil) ⇒ Object
- .alive_pid?(pid) ⇒ Boolean
- .allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST) ⇒ Object
-
.atomic_write_json(path, payload) ⇒ Object
Atomically replace a JSON state file.
- .build_adapter(cli, argv, legacy_pty: false) ⇒ Object
- .build_watch_config(path, repo_root) ⇒ Object
-
.canonical_repo_root(path) ⇒ Object
Canonical path resolution shared by every registry/exit/events writer and reader.
- .changed_worktree_paths(start_worktree, end_worktree) ⇒ Object
- .cli_key(cli) ⇒ Object
- .current_session_context(env = ENV) ⇒ Object
- .env_value(name, default: nil) ⇒ Object
- .events_log_path(repo_root, id) ⇒ Object
- .exit_status_path(repo_root, id) ⇒ Object
- .format_relay_message(text, from:, id:, at: Time.now) ⇒ Object
- .generate_id(repo_root) ⇒ Object
-
.git_capture_end(repo_root, start, exclude_paths: nil) ⇒ Object
Captures committed and in-progress work relative to the state observed at session start.
- .git_capture_start(repo_root, exclude_paths: []) ⇒ Object
- .git_null_paths(repo_root, *args) ⇒ Object
- .git_output(repo_root, *args) ⇒ Object
- .git_worktree_entry(repo_root, relative_path, tracked:) ⇒ Object
- .git_worktree_snapshot(repo_root, exclude_paths: []) ⇒ Object
- .harness_version ⇒ Object
- .host_info ⇒ Object
- .id_key(id) ⇒ Object
- .normalize_git_excluded_paths(paths) ⇒ Object
- .normalize_id(id) ⇒ Object
- .output_log_path(repo_root, id) ⇒ Object
- .parent_pid(pid) ⇒ Object
- .parse_duration_seconds(value, option_name:) ⇒ Object
- .parse_git_numstat(text) ⇒ Object
- .parse_git_shortstat(text) ⇒ Object
- .port_available?(host, port) ⇒ Boolean
- .process_state_for(session_state, terminal: false) ⇒ Object
- .read_registry(repo_root, id = DEFAULT_ID, cli: nil) ⇒ Object
- .registry_path(repo_root, id = DEFAULT_ID) ⇒ Object
- .repo_key(repo_root) ⇒ Object
- .resolve_repo_root(path = Dir.pwd) ⇒ Object
- .session_cli(session) ⇒ Object
- .session_file_slug(repo_root, id) ⇒ Object
- .strip_ansi(text) ⇒ Object
- .tmux_pane_for_pid(pid) ⇒ Object
- .untracked_worktree_stats(start_worktree, end_worktree, changed_paths) ⇒ Object
- .work_done_for(session_state, task_complete: false) ⇒ Object
- .work_state_for(session_state, task_complete: false) ⇒ Object
- .write_registry(path, payload) ⇒ Object
Class Method Details
.active_session_ids(repo_root) ⇒ Object
373 374 375 |
# File 'lib/harnex/core.rb', line 373 def active_session_ids(repo_root) active_sessions(repo_root).map { |session| session["id"].to_s.downcase }.to_set end |
.active_sessions(repo_root = nil, id: nil, cli: nil) ⇒ Object
416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 |
# File 'lib/harnex/core.rb', line 416 def active_sessions(repo_root = nil, id: nil, cli: nil) FileUtils.mkdir_p(SESSIONS_DIR) pattern = if repo_root File.join(SESSIONS_DIR, "#{repo_key(repo_root)}--*.json") else File.join(SESSIONS_DIR, "*.json") end target_id_key = id.nil? ? nil : id_key(id) normalized_cli = cli_key(cli) Dir.glob(pattern).sort.filter_map do |path| data = JSON.parse(File.read(path)) if data["pid"] && alive_pid?(data["pid"]) session = data.merge("registry_path" => path) next if target_id_key && id_key(session["id"].to_s) != target_id_key next if normalized_cli && cli_key(session_cli(session)) != normalized_cli session else FileUtils.rm_f(path) nil end rescue JSON::ParserError FileUtils.rm_f(path) nil end end |
.alive_pid?(pid) ⇒ Boolean
446 447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/harnex/core.rb', line 446 def alive_pid?(pid) Process.kill(0, Integer(pid)) true rescue Errno::ESRCH false rescue Errno::EPERM true rescue ArgumentError, TypeError # A non-numeric pid means a truncated, hand-edited, or foreign-written # registry file. Report it dead so active_sessions prunes it, the same # self-healing it already applies to unparseable JSON. Raising here would # take down every command that scans sessions -- status, send, pane. false end |
.allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST) ⇒ Object
547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 |
# File 'lib/harnex/core.rb', line 547 def allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST) if requested_port return requested_port if port_available?(host, requested_port) raise "port #{requested_port} is already in use on #{host}" end seed = Digest::SHA256.hexdigest("#{repo_root}\0#{normalize_id(id)}").to_i(16) offset = seed % DEFAULT_PORT_SPAN DEFAULT_PORT_SPAN.times do |index| port = DEFAULT_BASE_PORT + ((offset + index) % DEFAULT_PORT_SPAN) return port if port_available?(host, port) end raise "could not find a free port in #{DEFAULT_BASE_PORT}-#{DEFAULT_BASE_PORT + DEFAULT_PORT_SPAN - 1}" end |
.atomic_write_json(path, payload) ⇒ Object
Atomically replace a JSON state file.
The temp name must be unique per write, not just per process. Several threads in one session write the same registry path — the startup persist, the inbox delivery thread, and one thread per API client — and a shared temp name lets one thread rename the file another is still writing, which surfaces as ENOENT on the loser's rename. mkdir_p covers a state directory that was reaped (tmpfs, operator cleanup) while the process was live.
534 535 536 537 538 539 540 541 |
# File 'lib/harnex/core.rb', line 534 def atomic_write_json(path, payload) FileUtils.mkdir_p(File.dirname(path)) tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(6)}" File.write(tmp, JSON.pretty_generate(payload)) File.rename(tmp, path) ensure FileUtils.rm_f(tmp) if tmp end |
.build_adapter(cli, argv, legacy_pty: false) ⇒ Object
573 574 575 576 577 |
# File 'lib/harnex/core.rb', line 573 def build_adapter(cli, argv, legacy_pty: false) raise ArgumentError, "cli is required" if cli.to_s.strip.empty? Adapters.build(cli, argv, legacy_pty: legacy_pty) end |
.build_watch_config(path, repo_root) ⇒ Object
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 |
# File 'lib/harnex/core.rb', line 583 def build_watch_config(path, repo_root) return nil if path.nil? raise "file watch is unsupported on this system" unless Watcher.available? display_path = path.to_s.strip raise ArgumentError, "--watch requires a value" if display_path.empty? absolute_path = File.(display_path, repo_root) FileUtils.mkdir_p(File.dirname(absolute_path)) WatchConfig.new( absolute_path: absolute_path, display_path: display_path, hook_message: "file-change-hook: read #{display_path}", debounce_seconds: WATCH_DEBOUNCE_SECONDS ) end |
.canonical_repo_root(path) ⇒ Object
Canonical path resolution shared by every registry/exit/events writer and reader. realpath collapses symlinked prefixes so a session registered from a symlinked cwd stays visible to checkers resolving the physical path.
321 322 323 324 325 326 |
# File 'lib/harnex/core.rb', line 321 def canonical_repo_root(path) = File.(path) File.realpath() rescue StandardError end |
.changed_worktree_paths(start_worktree, end_worktree) ⇒ Object
275 276 277 278 279 280 281 282 283 |
# File 'lib/harnex/core.rb', line 275 def changed_worktree_paths(start_worktree, end_worktree) end_entries = end_worktree.fetch(:entries) return end_entries.keys.sort unless start_worktree.is_a?(Hash) start_entries = start_worktree[:entries] || start_worktree["entries"] || {} (start_entries.keys + end_entries.keys).uniq.select do |path| start_entries[path] != end_entries[path] end.sort end |
.cli_key(cli) ⇒ Object
343 344 345 346 347 348 |
# File 'lib/harnex/core.rb', line 343 def cli_key(cli) value = cli.to_s.strip.downcase return nil if value.empty? value.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "") end |
.current_session_context(env = ENV) ⇒ Object
350 351 352 353 354 355 356 357 358 359 360 361 362 363 |
# File 'lib/harnex/core.rb', line 350 def current_session_context(env = ENV) session_id = env["HARNEX_SESSION_ID"].to_s.strip cli = env["HARNEX_SESSION_CLI"].to_s.strip id = env["HARNEX_ID"].to_s.strip repo_root = env["HARNEX_SESSION_REPO_ROOT"].to_s.strip return nil if session_id.empty? || cli.empty? || id.empty? { session_id: session_id, cli: cli, id: id, repo_root: repo_root.empty? ? nil : repo_root } end |
.env_value(name, default: nil) ⇒ Object
14 15 16 |
# File 'lib/harnex/core.rb', line 14 def env_value(name, default: nil) ENV.fetch(name, default) end |
.events_log_path(repo_root, id) ⇒ Object
404 405 406 407 408 |
# File 'lib/harnex/core.rb', line 404 def events_log_path(repo_root, id) events_dir = File.join(STATE_DIR, "events") FileUtils.mkdir_p(events_dir) File.join(events_dir, "#{session_file_slug(repo_root, id)}.jsonl") end |
.exit_status_path(repo_root, id) ⇒ Object
392 393 394 395 396 |
# File 'lib/harnex/core.rb', line 392 def exit_status_path(repo_root, id) exit_dir = File.join(STATE_DIR, "exits") FileUtils.mkdir_p(exit_dir) File.join(exit_dir, "#{session_file_slug(repo_root, id)}.json") end |
.format_relay_message(text, from:, id:, at: Time.now) ⇒ Object
365 366 367 368 369 370 371 |
# File 'lib/harnex/core.rb', line 365 def (text, from:, id:, at: Time.now) header = "[harnex relay from=#{from} id=#{normalize_id(id)} at=#{at.iso8601}]" body = text.to_s return header if body.empty? "#{header}\n#{body}" end |
.generate_id(repo_root) ⇒ Object
377 378 379 380 381 382 383 384 385 |
# File 'lib/harnex/core.rb', line 377 def generate_id(repo_root) taken = active_session_ids(repo_root) ID_ADJECTIVES.product(ID_NOUNS).shuffle.each do |adj, noun| candidate = "#{adj}-#{noun}" return candidate unless taken.include?(candidate) end "session-#{SecureRandom.hex(4)}" end |
.git_capture_end(repo_root, start, exclude_paths: nil) ⇒ Object
Captures committed and in-progress work relative to the state observed at session start. Passing the full git_capture_start hash avoids treating an unchanged pre-existing dirty tree as worker activity; a SHA string remains supported for older callers.
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/harnex/core.rb', line 141 def git_capture_end(repo_root, start, exclude_paths: nil) start_capture = start.is_a?(Hash) ? start : {} start_sha = (start_capture[:sha] || start_capture["sha"] || start).to_s.strip return {} if start_sha.empty? excluded_paths ||= start_capture[:excluded_paths] || start_capture["excluded_paths"] || [] excluded_paths = normalize_git_excluded_paths(excluded_paths) end_sha = git_output(repo_root, "rev-parse", "HEAD") range = "#{start_sha}..#{end_sha}" commits = Integer(git_output(repo_root, "rev-list", "--count", range)) committed_paths = git_null_paths(repo_root, "diff", "--name-only", "-z", range) .reject { |path| excluded_paths.include?(path) } committed_stats = if committed_paths.empty? { loc_added: 0, loc_removed: 0 } else parse_git_numstat( git_output(repo_root, "diff", "--numstat", range, "--", *committed_paths.first(200)) ) end start_worktree = start_capture[:worktree] || start_capture["worktree"] end_worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths) worktree_paths = changed_worktree_paths(start_worktree, end_worktree) tracked_worktree_paths = worktree_paths.select do |path| entry = end_worktree.fetch(:entries).fetch(path, nil) entry && entry.fetch(:tracked) end worktree_stats = if tracked_worktree_paths.empty? { loc_added: 0, loc_removed: 0 } else parse_git_numstat( git_output(repo_root, "diff", "--numstat", end_sha, "--", *tracked_worktree_paths.first(200)) ) end untracked_stats = untracked_worktree_stats(start_worktree, end_worktree, worktree_paths) all_paths = (committed_paths + worktree_paths).uniq { sha: end_sha, loc_added: committed_stats.fetch(:loc_added) + worktree_stats.fetch(:loc_added) + untracked_stats.fetch(:loc_added), loc_removed: committed_stats.fetch(:loc_removed) + worktree_stats.fetch(:loc_removed) + untracked_stats.fetch(:loc_removed), files_changed: all_paths.length, changed_paths: all_paths.first(200), commits: commits, start_dirty: start_worktree ? !start_worktree.fetch(:entries).empty? : nil, end_dirty: !end_worktree.fetch(:entries).empty?, worktree_changed: !worktree_paths.empty? } rescue StandardError {} end |
.git_capture_start(repo_root, exclude_paths: []) ⇒ Object
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/harnex/core.rb', line 120 def git_capture_start(repo_root, exclude_paths: []) sha = git_output(repo_root, "rev-parse", "HEAD") branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD") return {} if sha.empty? || branch.empty? excluded_paths = normalize_git_excluded_paths(exclude_paths) worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths) { sha: sha, branch: branch, worktree: worktree, excluded_paths: excluded_paths } rescue StandardError {} end |
.git_null_paths(repo_root, *args) ⇒ Object
303 304 305 306 307 308 |
# File 'lib/harnex/core.rb', line 303 def git_null_paths(repo_root, *args) output, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args) raise "git #{args.join(' ')} failed" unless status.success? output.split("\0").reject(&:empty?) end |
.git_output(repo_root, *args) ⇒ Object
602 603 604 605 606 607 |
# File 'lib/harnex/core.rb', line 602 def git_output(repo_root, *args) stdout, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args) raise "git #{args.join(' ')} failed" unless status.success? stdout.strip end |
.git_worktree_entry(repo_root, relative_path, tracked:) ⇒ Object
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 |
# File 'lib/harnex/core.rb', line 218 def git_worktree_entry(repo_root, relative_path, tracked:) path = File.join(repo_root.to_s, relative_path) stat = File.lstat(path) if stat.symlink? content = File.readlink(path) return { tracked: tracked, fingerprint: "symlink:#{Digest::SHA256.hexdigest(content)}", lines: 0, binary: false } end return { tracked: tracked, fingerprint: "missing", lines: 0, binary: false } unless stat.file? if stat.size > GIT_FINGERPRINT_FULL_BYTES first = File.binread(path, GIT_FINGERPRINT_SAMPLE_BYTES) last = File.open(path, "rb") do |file| file.seek(-[stat.size, GIT_FINGERPRINT_SAMPLE_BYTES].min, IO::SEEK_END) file.read(GIT_FINGERPRINT_SAMPLE_BYTES).to_s end digest = Digest::SHA256.new digest.update("#{stat.mode}:#{stat.size}:#{(stat.mtime.to_r * 1_000_000_000).to_i}") digest.update(first) digest.update(last) return { tracked: tracked, fingerprint: "large:#{digest.hexdigest}", lines: 0, binary: first.include?("\0") || last.include?("\0") } end digest = Digest::SHA256.new lines = 0 binary = false last_byte = nil File.open(path, "rb") do |file| buffer = +"" while file.read(16 * 1024, buffer) digest.update(buffer) binary ||= buffer.include?("\0") lines += buffer.count("\n") last_byte = buffer.byteslice(-1) end end lines += 1 if stat.size.positive? && last_byte != "\n" { tracked: tracked, fingerprint: "file:#{stat.mode}:#{digest.hexdigest}", lines: binary ? 0 : lines, binary: binary } rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR { tracked: tracked, fingerprint: "missing", lines: 0, binary: false } end |
.git_worktree_snapshot(repo_root, exclude_paths: []) ⇒ Object
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/harnex/core.rb', line 193 def git_worktree_snapshot(repo_root, exclude_paths: []) excluded_paths = normalize_git_excluded_paths(exclude_paths) tracked_paths = ( git_null_paths(repo_root, "diff", "--name-only", "-z") + git_null_paths(repo_root, "diff", "--cached", "--name-only", "-z") ).uniq.reject { |path| excluded_paths.include?(path) } untracked_paths = git_null_paths(repo_root, "ls-files", "--others", "--exclude-standard", "-z") .reject { |path| excluded_paths.include?(path) } entries = {} tracked_paths.each do |path| entries[path] = git_worktree_entry(repo_root, path, tracked: true) end untracked_paths.each do |path| entries[path] = git_worktree_entry(repo_root, path, tracked: false) end { entries: entries } end |
.harness_version ⇒ Object
70 71 72 |
# File 'lib/harnex/core.rb', line 70 def harness_version VERSION end |
.host_info ⇒ Object
104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/harnex/core.rb', line 104 def host_info { host: Socket.gethostname, platform: RUBY_PLATFORM } rescue StandardError { host: nil, platform: RUBY_PLATFORM } end |
.id_key(id) ⇒ Object
339 340 341 |
# File 'lib/harnex/core.rb', line 339 def id_key(id) normalize_id(id).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "") end |
.normalize_git_excluded_paths(paths) ⇒ Object
211 212 213 214 215 216 |
# File 'lib/harnex/core.rb', line 211 def normalize_git_excluded_paths(paths) Array(paths).filter_map do |path| text = path.to_s.sub(%r{\A\./}, "") text unless text.empty? end.uniq end |
.normalize_id(id) ⇒ Object
332 333 334 335 336 337 |
# File 'lib/harnex/core.rb', line 332 def normalize_id(id) value = id.to_s.strip raise "id is required" if value.empty? value end |
.output_log_path(repo_root, id) ⇒ Object
398 399 400 401 402 |
# File 'lib/harnex/core.rb', line 398 def output_log_path(repo_root, id) output_dir = File.join(STATE_DIR, "output") FileUtils.mkdir_p(output_dir) File.join(output_dir, "#{session_file_slug(repo_root, id)}.log") end |
.parent_pid(pid) ⇒ Object
516 517 518 519 520 521 522 523 524 |
# File 'lib/harnex/core.rb', line 516 def parent_pid(pid) stat = File.read("/proc/#{pid}/stat") # Field 4 is ppid (fields are space-separated, field 1 is pid, # field 2 is (comm) which may contain spaces, field 3 is state, field 4 is ppid) parts = stat.match(/\A\d+\s+\(.*?\)\s+\S+\s+(\d+)/) parts ? parts[1].to_i : nil rescue Errno::ENOENT, Errno::EACCES nil end |
.parse_duration_seconds(value, option_name:) ⇒ Object
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/harnex/core.rb', line 44 def parse_duration_seconds(value, option_name:) text = value.to_s.strip raise OptionParser::InvalidArgument, "#{option_name} requires a value" if text.empty? match = text.match(/\A([0-9]+(?:\.[0-9]+)?)([smhSMH]?)\z/) unless match raise OptionParser::InvalidArgument, "#{option_name} must be a positive duration (examples: 30, 30s, 5m, 2h)" end amount = Float(match[1]) multiplier = case match[2].downcase when "", "s" then 1.0 when "m" then 60.0 when "h" then 3600.0 else raise OptionParser::InvalidArgument, "#{option_name} has an unsupported duration suffix" end seconds = amount * multiplier raise OptionParser::InvalidArgument, "#{option_name} must be greater than 0" if seconds <= 0.0 seconds end |
.parse_git_numstat(text) ⇒ Object
310 311 312 313 314 315 316 |
# File 'lib/harnex/core.rb', line 310 def parse_git_numstat(text) text.to_s.lines.each_with_object({ loc_added: 0, loc_removed: 0 }) do |line, stats| added, removed, = line.split("\t", 3) stats[:loc_added] += Integer(added, exception: false).to_i stats[:loc_removed] += Integer(removed, exception: false).to_i end end |
.parse_git_shortstat(text) ⇒ Object
609 610 611 612 613 614 615 |
# File 'lib/harnex/core.rb', line 609 def parse_git_shortstat(text) { files_changed: text.to_s[/(\d+)\s+files?\s+changed/, 1].to_i, loc_added: text.to_s[/(\d+)\s+insertions?\(\+\)/, 1].to_i, loc_removed: text.to_s[/(\d+)\s+deletions?\(-\)/, 1].to_i } end |
.port_available?(host, port) ⇒ Boolean
565 566 567 568 569 570 571 |
# File 'lib/harnex/core.rb', line 565 def port_available?(host, port) server = TCPServer.new(host, port) server.close true rescue Errno::EADDRINUSE, Errno::EACCES false end |
.process_state_for(session_state, terminal: false) ⇒ Object
93 94 95 96 97 98 99 100 101 102 |
# File 'lib/harnex/core.rb', line 93 def process_state_for(session_state, terminal: false) return "running" unless terminal case session_state.to_s when "completed", "failed" "exited" else "unknown" end end |
.read_registry(repo_root, id = DEFAULT_ID, cli: nil) ⇒ Object
461 462 463 464 465 466 |
# File 'lib/harnex/core.rb', line 461 def read_registry(repo_root, id = DEFAULT_ID, cli: nil) sessions = active_sessions(repo_root, id: id, cli: cli) return nil unless sessions.length == 1 sessions.first end |
.registry_path(repo_root, id = DEFAULT_ID) ⇒ Object
387 388 389 390 |
# File 'lib/harnex/core.rb', line 387 def registry_path(repo_root, id = DEFAULT_ID) FileUtils.mkdir_p(SESSIONS_DIR) File.join(SESSIONS_DIR, "#{session_file_slug(repo_root, id)}.json") end |
.repo_key(repo_root) ⇒ Object
328 329 330 |
# File 'lib/harnex/core.rb', line 328 def repo_key(repo_root) Digest::SHA256.hexdigest(canonical_repo_root(repo_root))[0, 16] end |
.resolve_repo_root(path = Dir.pwd) ⇒ Object
37 38 39 40 41 42 |
# File 'lib/harnex/core.rb', line 37 def resolve_repo_root(path = Dir.pwd) output, status = Open3.capture2("git", "rev-parse", "--show-toplevel", chdir: path, err: File::NULL) status.success? ? output.strip : File.(path) rescue StandardError File.(path) end |
.session_cli(session) ⇒ Object
579 580 581 |
# File 'lib/harnex/core.rb', line 579 def session_cli(session) (session["cli"] || Array(session["command"]).first).to_s end |
.session_file_slug(repo_root, id) ⇒ Object
410 411 412 413 414 |
# File 'lib/harnex/core.rb', line 410 def session_file_slug(repo_root, id) slug = id_key(id) slug = "default" if slug.empty? "#{repo_key(repo_root)}--#{slug}" end |
.strip_ansi(text) ⇒ Object
116 117 118 |
# File 'lib/harnex/core.rb', line 116 def strip_ansi(text) text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "") end |
.tmux_pane_for_pid(pid) ⇒ Object
468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 |
# File 'lib/harnex/core.rb', line 468 def tmux_pane_for_pid(pid) target_pid = Integer(pid) stdout, status = Open3.capture2( "tmux", "list-panes", "-a", "-F", "\#{pane_id}\t\#{pane_pid}\t\#{session_name}\t\#{window_name}" ) return nil unless status.success? panes = stdout.each_line.filter_map do |line| pane_id, pane_pid, session_name, window_name = line.chomp.split("\t", 4) next if pane_id.to_s.empty? { target: pane_id, pane_id: pane_id, pane_pid: pane_pid.to_i, session_name: session_name, window_name: window_name } end pane_pids = panes.map { |p| p[:pane_pid] }.to_set # Direct match first matches = panes.select { |p| p[:pane_pid] == target_pid } # If no direct match, walk up the process tree from target_pid # to find an ancestor that is a tmux pane root process. if matches.empty? ancestor = parent_pid(target_pid) while ancestor && ancestor > 1 if pane_pids.include?(ancestor) matches = panes.select { |p| p[:pane_pid] == ancestor } break end ancestor = parent_pid(ancestor) end end return nil unless matches.length == 1 result = matches.first result.delete(:pane_pid) result rescue ArgumentError, Errno::ENOENT nil end |
.untracked_worktree_stats(start_worktree, end_worktree, changed_paths) ⇒ Object
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'lib/harnex/core.rb', line 285 def untracked_worktree_stats(start_worktree, end_worktree, changed_paths) start_entries = if start_worktree.is_a?(Hash) start_worktree[:entries] || start_worktree["entries"] || {} else {} end end_entries = end_worktree.fetch(:entries) changed_paths.each_with_object({ loc_added: 0, loc_removed: 0 }) do |path, stats| before = start_entries[path] after = end_entries[path] next if after && after.fetch(:tracked) next if before && before.fetch(:tracked) stats[:loc_removed] += before.fetch(:lines) if before stats[:loc_added] += after.fetch(:lines) if after end end |
.work_done_for(session_state, task_complete: false) ⇒ Object
89 90 91 |
# File 'lib/harnex/core.rb', line 89 def work_done_for(session_state, task_complete: false) work_state_for(session_state, task_complete: task_complete) == "completed" end |
.work_state_for(session_state, task_complete: false) ⇒ Object
74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/harnex/core.rb', line 74 def work_state_for(session_state, task_complete: false) return "completed" if task_complete case session_state.to_s when "completed" "completed" when "failed" "failed" when "running" "running" else "unknown" end end |
.write_registry(path, payload) ⇒ Object
543 544 545 |
# File 'lib/harnex/core.rb', line 543 def write_registry(path, payload) atomic_write_json(path, payload) end |