robot_lab-discovery 0.2.6

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 24e1bc6292612b2ea663e6263851de0dedff0d271e16e8f2d19880c6b434e0e4
4
+ data.tar.gz: 7980129dba8853c6f5ce97f0792362a5d64aad6685e23a2b650cb98d35334bf6
5
+ SHA512:
6
+ metadata.gz: d0055e91603ceae005545f8fe8707f33ddfb8f8031631b840b73befa6960d2cfa8bb8975d6733ea3380879f5230d87063ca62f6f4465bd8f93ba03258ad9b35f
7
+ data.tar.gz: d5a0eadd8e1ae6285654468f6a3be30e5903fce881799295e3fcf199e99670339d1a64430ebd2c4140bfebdd03422465cedc3e425e35aaa0ad847b5d4326fdfb
data/.envrc ADDED
@@ -0,0 +1 @@
1
+ export RR=`pwd`
@@ -0,0 +1,52 @@
1
+ name: Deploy Documentation to GitHub Pages
2
+ on:
3
+ push:
4
+ branches:
5
+ - main
6
+ - develop
7
+ paths:
8
+ - "docs/**"
9
+ - "mkdocs.yml"
10
+ - ".github/workflows/deploy-github-pages.yml"
11
+ workflow_dispatch:
12
+
13
+ permissions:
14
+ contents: write
15
+ pages: write
16
+ id-token: write
17
+
18
+ jobs:
19
+ deploy:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Checkout code
23
+ uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0
26
+
27
+ - name: Setup Python
28
+ uses: actions/setup-python@v5
29
+ with:
30
+ python-version: 3.x
31
+
32
+ - name: Install dependencies
33
+ run: |
34
+ pip install mkdocs
35
+ pip install mkdocs-material
36
+ pip install mkdocs-macros-plugin
37
+ pip install mike
38
+
39
+ - name: Configure Git
40
+ run: |
41
+ git config --local user.email "action@github.com"
42
+ git config --local user.name "GitHub Action"
43
+
44
+ - name: Build MkDocs site
45
+ run: mkdocs build
46
+
47
+ - name: Deploy to GitHub Pages
48
+ uses: peaceiris/actions-gh-pages@v4
49
+ with:
50
+ github_token: ${{ secrets.GITHUB_TOKEN }}
51
+ publish_dir: ./site
52
+ keep_files: true
data/.loki ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ # robot_lab-discovery — zeroconf/mDNS service discovery.
3
+
4
+ import_up "repo_dev.loki"
5
+
6
+ class Tasks
7
+ @@gem_name ||= "robot_lab-discovery".freeze
8
+
9
+ header "robot_lab-discovery v#{gem_version} — service discovery"
10
+ end
@@ -0,0 +1,2 @@
1
+ 1 lib/robot_lab/discovery/browser.rb
2
+ 1 lib/robot_lab/discovery/txt_record.rb
data/.rubocop.yml ADDED
@@ -0,0 +1 @@
1
+ inherit_from: ../.rubocop-base.yml
data/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-05-28
4
+
5
+ ### Added
6
+
7
+ - `RobotLab::Discovery::Advertiser` — registers a robot as a `_robot-lab._tcp.local.`
8
+ mDNS service in a background thread. Accepts a free-form `capabilities:` array;
9
+ each capability is encoded in the TXT record and registered as a DNS-SD subtype
10
+ (`_research._sub._robot-lab._tcp.local.` etc.) for targeted browsing.
11
+ Supports `start`, `stop`, and `started?`. Sends an mDNS goodbye packet (TTL=0)
12
+ on `stop` to remove the service from peers' caches immediately.
13
+ - `RobotLab::Discovery::Browser` — browses `_robot-lab._tcp.local.` via
14
+ `ZeroConf.browse`, parses SRV and TXT records, and returns `Result` objects.
15
+ Provides:
16
+ - `browse(timeout:)` — all robots on the LAN
17
+ - `find(name, timeout:)` — single robot by instance name
18
+ - `find_by_capability(capability, timeout:)` — robots advertising a given
19
+ capability, browsed via the corresponding DNS-SD subtype
20
+ - `list_capabilities(timeout:)` — sorted, deduplicated list of all capability
21
+ terms currently advertised on the LAN
22
+ - `RobotLab::Discovery::Result` — immutable `Data`-based value object with
23
+ `name`, `hostname`, `port`, `path`, `capabilities`, and a `url` helper method.
24
+ - `RobotLab::Discovery::TxtRecord` — encodes and decodes DNS TXT record arrays.
25
+ Fields: `path=`, `rl_version=`, `capabilities=` (comma-separated).
26
+ - `RobotLab::Discovery.dns_label` — normalises a free-form capability string
27
+ into a valid DNS label (downcased, non-alphanumeric runs collapsed to hyphens).
28
+ - `RobotLab::Discovery` module with `browse`, `find`, `find_by_capability`, and
29
+ `list_capabilities` convenience class methods. Self-registers as a RobotLab
30
+ extension when the core gem is present.
31
+ - `examples/01_basic_usage.rb` — end-to-end demo covering all six operations:
32
+ register with capabilities, browse all, find by name, find by capability,
33
+ list capability inventory, and unregister.
data/CLAUDE.md ADDED
@@ -0,0 +1,74 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What This Gem Does
6
+
7
+ `robot_lab-discovery` provides zero-configuration mDNS/DNS-SD robot discovery on local networks. Robots advertise themselves with a name, port, path, and optional capabilities; browsers discover them by name or capability without any central registry.
8
+
9
+ Uses the `zeroconf` gem. Service type is `_robot-lab._tcp.local.`.
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ bundle exec rake test # Run full test suite (SpecReporter output)
15
+ ruby -Ilib:test test/<file> # Run a single test file
16
+ ```
17
+
18
+ ## Architecture
19
+
20
+ All source lives under `lib/robot_lab/discovery/`.
21
+
22
+ **`Discovery`** (`discovery.rb`) — Top-level module with delegation methods:
23
+ - `browse(timeout:)` — returns all visible robots as `Array<Result>`
24
+ - `find(name, timeout:)` — polls until the named robot appears or timeout
25
+ - `find_by_capability(cap, timeout:)` — browses the capability-specific DNS subtype
26
+ - `list_capabilities(timeout:)` — returns sorted unique capability strings across all visible robots
27
+ - `dns_label(capability)` — normalises a capability string to a valid DNS label (`"Web Search"` → `"web-search"`)
28
+
29
+ **`Advertiser`** (`discovery/advertiser.rb`) — Announces a robot over mDNS. `start` spawns a background thread running the zeroconf service; `stop` tears it down. Each capability is registered as a DNS subtype (`_capability-label._sub._robot-lab._tcp.local.`) enabling capability-filtered browsing.
30
+
31
+ **`Browser`** (`discovery/browser.rb`) — Wraps `ZeroConf.browse`. Parses raw DNS responses (SRV + TXT records) into `Result` structs, deduplicating by name. `parse_response` is private — do not call directly.
32
+
33
+ **`TxtRecord`** (`discovery/txt_record.rb`) — Encodes/decodes the DNS TXT payload using short wire keys to conserve UDP packet space:
34
+ - `p` = path, `v` = rl_version, `c` = comma-joined capabilities
35
+ - `encode` validates each string ≤ 255 bytes and total wire size ≤ 1300 bytes
36
+ - `decode` returns `{ path:, rl_version:, capabilities: [] }`
37
+
38
+ **`Result`** (`discovery/result.rb`) — `Data.define(:name, :hostname, :port, :path, :capabilities)`. Has a `url` convenience method (`"http://hostname:port/path"`).
39
+
40
+ **`Constants`** (`discovery/constants.rb`) — `SERVICE_TYPE = "_robot-lab._tcp.local."`.
41
+
42
+ ## Usage Example
43
+
44
+ ```ruby
45
+ require 'robot_lab/discovery'
46
+
47
+ # Advertise
48
+ adv = RobotLab::Discovery::Advertiser.new(
49
+ name: "analyst", port: 9292, path: "/a2a",
50
+ capabilities: ["data analysis", "web search"]
51
+ )
52
+ adv.start
53
+
54
+ # Browse
55
+ robots = RobotLab::Discovery.browse(timeout: 3)
56
+ robots.each { |r| puts "#{r.name} at #{r.url}" }
57
+
58
+ # Find by capability
59
+ RobotLab::Discovery.find_by_capability("web search", timeout: 3)
60
+ ```
61
+
62
+ ## Key Constraints
63
+
64
+ - mDNS is LAN-only — works within a single subnet, not across routers.
65
+ - `browse` may return stale results if a robot crashes without calling `stop` — mDNS TTLs eventually expire.
66
+ - Capability strings in TXT records are comma-separated in a single field capped at 253 bytes of content. Many short capabilities are fine; very long strings will raise `Discovery::Error`.
67
+ - `find` polls by calling `browse` in a loop — it is blocking and should not be called on the main thread in production.
68
+
69
+ ## Testing
70
+
71
+ - Minitest with SimpleCov (branch coverage tracked, no minimum threshold enforced yet)
72
+ - Output via `Minitest::Reporters::SpecReporter`
73
+ - Tests mock zeroconf interactions — no actual mDNS traffic is sent
74
+ - Coverage baseline: ~47% line / ~3% branch — test suite is thin relative to implementation
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dewayne VanHoozer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # robot_lab-discovery
2
+
3
+ Zero-configuration mDNS/DNS-SD robot discovery for [RobotLab](https://github.com/MadBomber/robot_lab) on local networks.
4
+
5
+ Robots advertise themselves via multicast DNS (mDNS, RFC 6762) and find each other without any central registry or hardcoded URLs. Augments `robot_lab-a2a` but has no dependency on it — any transport can use the discovered URL.
6
+
7
+ ## How it works
8
+
9
+ Each robot registers as a `_robot-lab._tcp.local.` mDNS service. Its HTTP path, gem version, and capability taxonomy are stored in the TXT record. Each capability is also registered as a DNS-SD subtype, enabling clients to browse by capability type without enumerating all robots.
10
+
11
+ ```
12
+ Advertiser Browser
13
+ ────────────────────────────────────── ────────────────────────────────────────────
14
+ ZeroConf::Service ZeroConf.browse("_robot-lab._tcp.local.")
15
+ instance: "headline" → parse SRV + TXT
16
+ host: my-server.local → Result(name, hostname, port, path,
17
+ port: 9292 capabilities, url)
18
+ TXT: path=/headline
19
+ capabilities=writing,research
20
+ subtypes: _writing._sub._robot-lab._tcp.local.
21
+ _research._sub._robot-lab._tcp.local.
22
+
23
+ ZeroConf.browse("_research._sub._robot-lab._tcp.local.")
24
+ → only research robots
25
+ ```
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ bundle add robot_lab-discovery
31
+ ```
32
+
33
+ Or add to your Gemfile:
34
+
35
+ ```ruby
36
+ gem "robot_lab-discovery"
37
+ ```
38
+
39
+ ### System prerequisites
40
+
41
+ None beyond Ruby >= 3.2. The `zeroconf` gem is pure Ruby and implements mDNS itself using standard UDP multicast sockets — it does not wrap Bonjour, Avahi, or any system mDNS daemon. A multicast-capable network interface is all that is required, which is standard on any LAN.
42
+
43
+ ## Usage
44
+
45
+ ### Advertising a robot
46
+
47
+ ```ruby
48
+ require "robot_lab/discovery"
49
+
50
+ adv = RobotLab::Discovery::Advertiser.new(
51
+ name: "headline", # mDNS instance name — no dots allowed
52
+ port: 9292,
53
+ path: "/headline", # stored in TXT record
54
+ hostname: Socket.gethostname,
55
+ capabilities: ["writing", "research"] # free-form taxonomy terms
56
+ )
57
+
58
+ adv.start # begins advertising in a background thread
59
+ # ... server runs ...
60
+ adv.stop # sends mDNS goodbye packet; removes from peers' caches
61
+ ```
62
+
63
+ Capability strings are free-form. They are normalised into valid DNS labels
64
+ (`"Web Search"` → `"web-search"`) and registered as DNS-SD subtypes, so peers
65
+ can browse for them directly.
66
+
67
+ ### Discovering robots
68
+
69
+ ```ruby
70
+ require "robot_lab/discovery"
71
+
72
+ # All robots on the LAN
73
+ results = RobotLab::Discovery.browse(timeout: 3)
74
+ results.each { |r| puts "#{r.name}: #{r.url} caps=#{r.capabilities.join(", ")}" }
75
+
76
+ # Find one robot by name
77
+ result = RobotLab::Discovery.find("headline", timeout: 5)
78
+ result.url # => "http://my-server.local:9292/headline"
79
+ result.capabilities # => ["writing", "research"]
80
+
81
+ # Find all robots offering a specific capability
82
+ researchers = RobotLab::Discovery.find_by_capability("research", timeout: 3)
83
+
84
+ # List every capability type advertised on the LAN right now
85
+ RobotLab::Discovery.list_capabilities(timeout: 3)
86
+ # => ["analysis", "coding", "research", "writing"]
87
+ ```
88
+
89
+ ### Connecting after discovery
90
+
91
+ `Result#url` returns a plain URL string. Wire it to whatever client you use:
92
+
93
+ ```ruby
94
+ result = RobotLab::Discovery.find("headline")
95
+
96
+ # With robot_lab-a2a:
97
+ client = A2A.client(url: result.url)
98
+ client.send_task(message: A2A::Models::Message.user("Summarise today's news"))
99
+
100
+ # With Net::HTTP directly:
101
+ Net::HTTP.get(URI(result.url))
102
+ ```
103
+
104
+ ### Result fields
105
+
106
+ | Field | Type | Description |
107
+ |----------------|---------------|-------------|
108
+ | `name` | String | mDNS instance name (e.g. `"headline"`) |
109
+ | `hostname` | String | Fully-qualified `.local` hostname |
110
+ | `port` | Integer | Port the robot server listens on |
111
+ | `path` | String | HTTP path to the robot (e.g. `"/headline"`) |
112
+ | `capabilities` | Array<String> | Capability taxonomy terms advertised by this robot |
113
+ | `url` | String | `"http://#{hostname}:#{port}#{path}"` |
114
+
115
+ ## Capability taxonomy
116
+
117
+ Capabilities are free-form strings — there is no enforced registry. Use short,
118
+ lowercase, hyphen-separated terms that describe what the robot does, not how it
119
+ works. Examples:
120
+
121
+ ```
122
+ research analysis writing coding
123
+ vision memory tool-use orchestration
124
+ web-search summarisation classification
125
+ ```
126
+
127
+ A robot may advertise any number of capabilities. Clients browse by capability
128
+ using `find_by_capability`, which targets the DNS-SD subtype directly and avoids
129
+ scanning all robots on the network.
130
+
131
+ `dns_label` is available for normalising arbitrary strings:
132
+
133
+ ```ruby
134
+ RobotLab::Discovery.dns_label("Web Search / NLP") # => "web-search-nlp"
135
+ ```
136
+
137
+ ## Examples
138
+
139
+ ```bash
140
+ bundle exec ruby examples/01_basic_usage.rb
141
+ ```
142
+
143
+ Covers all six operations: register with capabilities, browse all, find by name,
144
+ find by capability, list capability inventory, and unregister.
145
+
146
+ ## Architecture
147
+
148
+ ```
149
+ lib/robot_lab/discovery/
150
+ constants.rb # SERVICE_TYPE = "_robot-lab._tcp.local."
151
+ txt_record.rb # encode/decode TXT record arrays (path, rl_version, capabilities)
152
+ result.rb # immutable Data value object (name, hostname, port, path, capabilities)
153
+ advertiser.rb # wraps ZeroConf::Service, runs in a thread, registers subtypes
154
+ browser.rb # wraps ZeroConf.browse, parses DNS responses
155
+ ```
156
+
157
+ `RobotLab::Discovery` module exposes `browse`, `find`, `find_by_capability`, and
158
+ `list_capabilities` as class methods. If `RobotLab` is loaded, the gem
159
+ self-registers as an extension via `RobotLab.register_extension(:discovery, ...)`.
160
+
161
+ ## Relationship to other robot_lab gems
162
+
163
+ | Gem | Concern |
164
+ |-----|---------|
165
+ | `robot_lab` | Core robots, networks, memory |
166
+ | `robot_lab-a2a` | A2A HTTP+SSE transport protocol |
167
+ | `robot_lab-discovery` | **Where** are the robots and **what can they do** (mDNS addressing + capability taxonomy) |
168
+
169
+ Discovery is intentionally transport-agnostic. It finds a URL; you decide what
170
+ protocol to speak to it.
171
+
172
+ ## Development
173
+
174
+ ```bash
175
+ bundle install
176
+ bundle exec rake test # run the test suite
177
+ bin/console # IRB with the gem loaded
178
+ ```
179
+
180
+ ## License
181
+
182
+ MIT — see [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb', 'test/**/test_*.rb'].exclude('**/*_helper.rb')
10
+ t.verbose = true
11
+ t.ruby_opts << '-rtest_helper'
12
+ end
13
+
14
+ task default: :test
15
+
16
+ desc 'Run tests with verbose output'
17
+ task :test_verbose do
18
+ ENV['TESTOPTS'] = '--verbose'
19
+ Rake::Task[:test].invoke
20
+ end
21
+
22
+ desc 'Run a single test file'
23
+ task :test_file, [:file] do |_t, args|
24
+ ruby "test/#{args[:file]}"
25
+ end
26
+
27
+ desc 'Check code complexity with Flog (warn >=20, fail >=50)'
28
+ task :flog_check do
29
+ require 'flog'
30
+
31
+ method_warn = 20.0
32
+ method_fail = 50.0
33
+
34
+ flogger = Flog.new(all: true)
35
+ flogger.flog(*Dir.glob('lib/**/*.rb'))
36
+
37
+ warnings = []
38
+ failures = []
39
+
40
+ flogger.each_by_score do |method, score|
41
+ next if method.end_with?('#none')
42
+
43
+ if score > method_fail
44
+ failures << "#{format('%.1f', score)}: #{method}"
45
+ elsif score > method_warn
46
+ warnings << "#{format('%.1f', score)}: #{method}"
47
+ end
48
+ end
49
+
50
+ unless warnings.empty?
51
+ puts "\nFlog warnings (#{method_warn}–#{method_fail}) — target for future refactoring:"
52
+ warnings.each { |v| puts " #{v}" }
53
+ end
54
+
55
+ if failures.empty?
56
+ puts "\nFlog: no methods exceed the failure threshold (>=#{method_fail})"
57
+ else
58
+ puts "\nFlog failures (>=#{method_fail}) — must be refactored:"
59
+ failures.each { |v| puts " #{v}" }
60
+ abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{method_fail}"
61
+ end
62
+ end
63
+
64
+ desc 'Check for structural code duplication with Flay (mass >= 50)'
65
+ task :flay_check do
66
+ require 'flay'
67
+
68
+ mass_threshold = 50
69
+
70
+ flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
71
+ flay.process(*Dir.glob('lib/**/*.rb'))
72
+ flay.analyze
73
+
74
+ if flay.hashes.empty?
75
+ puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
76
+ else
77
+ puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
78
+ flay.report
79
+ abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
80
+ end
81
+ end
82
+
83
+ desc 'Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay'
84
+ task :quality do
85
+ gates = [
86
+ ['Tests + Coverage', 'bundle exec rake test'],
87
+ ['RuboCop', 'bundle exec rubocop'],
88
+ ['Flog Complexity', 'bundle exec rake flog_check'],
89
+ ['Flay Duplication', 'bundle exec rake flay_check']
90
+ ]
91
+
92
+ results = gates.map do |label, command|
93
+ puts "\n#{'=' * 60}"
94
+ puts "Quality Gate: #{label}"
95
+ puts '=' * 60
96
+ [label, system(command) ? :pass : :fail]
97
+ end
98
+
99
+ green = ->(s) { "\e[32m#{s}\e[0m" }
100
+ red = ->(s) { "\e[31m#{s}\e[0m" }
101
+ width = results.map { |label, _| label.length }.max
102
+
103
+ puts "\n#{'=' * 60}"
104
+ puts 'Quality Gate Summary'
105
+ puts '=' * 60
106
+ results.each do |label, status|
107
+ badge = status == :pass ? green.call('PASS') : red.call('FAIL')
108
+ puts " [#{badge}] #{label.ljust(width)}"
109
+ end
110
+ puts '-' * 60
111
+
112
+ passed = results.count { |_, s| s == :pass }
113
+ failed = results.count { |_, s| s == :fail }
114
+ tally = "#{passed} passed, #{failed} failed"
115
+ puts " #{failed.zero? ? green.call(tally) : red.call(tally)}"
116
+ puts '=' * 60
117
+
118
+ abort "\n#{red.call('Quality gate failed.')}" unless failed.zero?
119
+ puts "\n#{green.call('All quality gates passed.')}"
120
+ end
121
+
122
+ namespace :docs do
123
+ desc 'Build MkDocs documentation'
124
+ task :build do
125
+ sh 'mkdocs build'
126
+ end
127
+
128
+ desc 'Serve MkDocs documentation locally on http://localhost:8000'
129
+ task :serve do
130
+ sh 'mkdocs serve'
131
+ end
132
+ end