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 +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +2 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +33 -0
- data/CLAUDE.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/Rakefile +132 -0
- data/docs/api_reference.md +164 -0
- data/docs/getting_started.md +114 -0
- data/docs/how_it_works.md +131 -0
- data/docs/index.md +58 -0
- data/examples/01_basic_usage.rb +154 -0
- data/examples/common.rb +9 -0
- data/lib/robot_lab/discovery/advertiser.rb +45 -0
- data/lib/robot_lab/discovery/browser.rb +69 -0
- data/lib/robot_lab/discovery/constants.rb +7 -0
- data/lib/robot_lab/discovery/result.rb +9 -0
- data/lib/robot_lab/discovery/txt_record.rb +70 -0
- data/lib/robot_lab/discovery/version.rb +7 -0
- data/lib/robot_lab/discovery.rb +30 -0
- data/mkdocs.yml +117 -0
- data/sig/robot_lab/discovery.rbs +6 -0
- metadata +84 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
Every public class, module, and method in `robot_lab-discovery`. See [How It Works](how_it_works.md) for the concepts and wire-level detail behind each one.
|
|
4
|
+
|
|
5
|
+
## `RobotLab::Discovery` (module)
|
|
6
|
+
|
|
7
|
+
Top-level convenience methods — thin delegators to `Browser`, plus the one standalone utility method.
|
|
8
|
+
|
|
9
|
+
### `browse(timeout: 3) → Array<Result>`
|
|
10
|
+
|
|
11
|
+
All robots currently visible on the LAN, deduplicated by name. Delegates to `Browser.browse`.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
RobotLab::Discovery.browse(timeout: 3).each { |r| puts r.url }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### `find(name, timeout: 5) → Result, nil`
|
|
18
|
+
|
|
19
|
+
Polls (see [How It Works](how_it_works.md#findname-timeout)) until a robot named `name` appears or `timeout` elapses. Returns `nil` if it never appears. Delegates to `Browser.find`.
|
|
20
|
+
|
|
21
|
+
### `find_by_capability(cap, timeout: 3) → Array<Result>`
|
|
22
|
+
|
|
23
|
+
Robots advertising capability `cap`, found by browsing that capability's DNS-SD subtype directly (a real network-level filter, not client-side). Delegates to `Browser.find_by_capability`.
|
|
24
|
+
|
|
25
|
+
### `list_capabilities(timeout: 3) → Array<String>`
|
|
26
|
+
|
|
27
|
+
Every capability string currently advertised by any robot on the LAN, deduplicated and sorted alphabetically. Delegates to `Browser.list_capabilities`.
|
|
28
|
+
|
|
29
|
+
### `dns_label(capability) → String`
|
|
30
|
+
|
|
31
|
+
Normalizes a free-form capability string into a valid DNS label: downcased, non-alphanumeric runs collapsed to a single hyphen, no leading/trailing hyphen.
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
RobotLab::Discovery.dns_label("Web Search") # => "web-search"
|
|
35
|
+
RobotLab::Discovery.dns_label("NLP/Analysis") # => "nlp-analysis"
|
|
36
|
+
RobotLab::Discovery.dns_label("Web Search / NLP") # => "web-search-nlp"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### `Error`
|
|
40
|
+
|
|
41
|
+
`RobotLab::Discovery::Error < StandardError` — raised only by `TxtRecord.validate!` (see below) when a TXT record exceeds its size limits.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## `RobotLab::Discovery::Advertiser`
|
|
46
|
+
|
|
47
|
+
Announces one robot over mDNS.
|
|
48
|
+
|
|
49
|
+
### `new(name:, port:, path:, hostname: Socket.gethostname, capabilities: [])`
|
|
50
|
+
|
|
51
|
+
| Parameter | Type | Default | Description |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `name` | `String` | *(required)* | mDNS instance name. Must not contain dots. |
|
|
54
|
+
| `port` | `Integer` | *(required)* | port the robot's server listens on |
|
|
55
|
+
| `path` | `String` | *(required)* | HTTP path to the robot; stored in the TXT record, not validated by this gem |
|
|
56
|
+
| `hostname` | `String` | `Socket.gethostname` | the host to advertise; override on a multi-homed machine |
|
|
57
|
+
| `capabilities` | `Array<String>`, `String` | `[]` | free-form taxonomy terms; coerced to an array of strings |
|
|
58
|
+
|
|
59
|
+
Only assigns instance variables — no network activity happens until `#start` is called.
|
|
60
|
+
|
|
61
|
+
### `start → self`
|
|
62
|
+
|
|
63
|
+
Builds a `ZeroConf::Service` for `Constants::SERVICE_TYPE`, encoding `path:`/`capabilities:` into the TXT record via `TxtRecord.encode` (raises `RobotLab::Discovery::Error` here if the record is too large — see [How It Works](how_it_works.md#size-limits-and-why-they-exist)) and registering one DNS-SD subtype per capability. Spawns the actual service on a background `Thread` and returns `self` immediately — does not block.
|
|
64
|
+
|
|
65
|
+
### `stop → self`
|
|
66
|
+
|
|
67
|
+
Stops the `ZeroConf::Service` (sends an mDNS goodbye packet, TTL=0) and joins the background thread before returning — by the time `stop` returns, advertising has fully ended.
|
|
68
|
+
|
|
69
|
+
### `started? → Boolean`
|
|
70
|
+
|
|
71
|
+
`true` while the background thread is alive; `false` before `start` or after `stop`.
|
|
72
|
+
|
|
73
|
+
### Attributes
|
|
74
|
+
|
|
75
|
+
`name`, `port`, `path`, `hostname`, `capabilities` — all readers, reflecting the values passed to `new`.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## `RobotLab::Discovery::Browser` (module)
|
|
80
|
+
|
|
81
|
+
Stateless class methods that do the actual mDNS browsing and response parsing. `RobotLab::Discovery.browse`/`find`/`find_by_capability`/`list_capabilities` are thin delegators to these.
|
|
82
|
+
|
|
83
|
+
### `browse(timeout: 3) → Array<Result>`
|
|
84
|
+
|
|
85
|
+
`ZeroConf.browse(SERVICE_TYPE, timeout:)`, parsed into `Result`s, deduplicated by `name`.
|
|
86
|
+
|
|
87
|
+
### `find(name, timeout: 5) → Result, nil`
|
|
88
|
+
|
|
89
|
+
Polls `browse` in ≤1-second increments until `name` matches or `timeout` elapses; `nil` on timeout. See [How It Works](how_it_works.md#findname-timeout) for why this is a real loop, not a single call.
|
|
90
|
+
|
|
91
|
+
### `find_by_capability(capability, timeout: 3) → Array<Result>`
|
|
92
|
+
|
|
93
|
+
Browses `"_#{dns_label(capability)}._sub.#{SERVICE_TYPE}"` instead of the base service type.
|
|
94
|
+
|
|
95
|
+
### `list_capabilities(timeout: 3) → Array<String>`
|
|
96
|
+
|
|
97
|
+
`browse(timeout:).flat_map(&:capabilities).uniq.sort`.
|
|
98
|
+
|
|
99
|
+
### `parse_response(response)` *(private — not part of the public API)*
|
|
100
|
+
|
|
101
|
+
Extracts SRV (`port`, `hostname`, instance name) and TXT (`path`, `capabilities`) data from a raw mDNS response and builds a `Result`. Returns `nil` for a malformed/partial response (missing SRV or missing `path`) rather than raising — this is why callers use `filter_map`, not `map`. Documented here only because `CLAUDE.md` calls it out explicitly: **do not call this directly**; it's an implementation detail of `browse`/`find_by_capability`.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## `RobotLab::Discovery::Result`
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Result = Data.define(:name, :hostname, :port, :path, :capabilities)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
An immutable value object — every field below is a plain reader; there are no writers.
|
|
112
|
+
|
|
113
|
+
| Field | Type | Description |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `name` | `String` | mDNS instance name |
|
|
116
|
+
| `hostname` | `String` | fully-qualified `.local` hostname (trailing DNS root dot already stripped) |
|
|
117
|
+
| `port` | `Integer` | port the robot's server listens on |
|
|
118
|
+
| `path` | `String` | HTTP path to the robot |
|
|
119
|
+
| `capabilities` | `Array<String>` | capability taxonomy terms this robot advertises (`[]` if none) |
|
|
120
|
+
|
|
121
|
+
#### `url → String`
|
|
122
|
+
|
|
123
|
+
`"http://#{hostname}:#{port}#{path}"`. Always plain HTTP — this gem has no notion of a scheme beyond that; build the URL yourself if a robot serves over HTTPS.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## `RobotLab::Discovery::TxtRecord` (module)
|
|
128
|
+
|
|
129
|
+
Encodes/decodes the DNS TXT record payload. Normally used internally by `Advertiser`/`Browser`; documented publicly because the wire format and size limits are useful to understand when debugging a discovery issue or building a compatible advertiser in another language.
|
|
130
|
+
|
|
131
|
+
### Constants
|
|
132
|
+
|
|
133
|
+
| Constant | Value | Meaning |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `PATH_KEY` | `"p"` | wire key for the HTTP path |
|
|
136
|
+
| `VERSION_KEY` | `"v"` | wire key for the gem's version at encode time |
|
|
137
|
+
| `CAPABILITIES_KEY` | `"c"` | wire key for the comma-joined capability list |
|
|
138
|
+
| `MAX_STRING_BYTES` | `255` | RFC 1035 §3.3.14 per-string limit — not configurable |
|
|
139
|
+
| `MAX_TOTAL_BYTES` | `1300` | wire-size budget kept under the UDP/Ethernet MTU to avoid fragmentation |
|
|
140
|
+
| `MAX_CAPABILITIES_CONTENT` | `253` | bytes available for the capabilities string's content (`255 - "c".bytesize - "=".bytesize`) |
|
|
141
|
+
|
|
142
|
+
### `encode(path:, capabilities: []) → Array<String>`
|
|
143
|
+
|
|
144
|
+
Builds `["p=#{path}", "v=#{VERSION}"]`, appending `"c=#{capabilities.join(',')}"` only when `capabilities` is non-empty, then validates the result via `validate!` (raising `RobotLab::Discovery::Error` on a size violation) before returning it.
|
|
145
|
+
|
|
146
|
+
### `decode(strings) → Hash`
|
|
147
|
+
|
|
148
|
+
Parses an array of `"key=value"` strings (splitting on the *first* `=` only, so a value itself containing `=` round-trips correctly) into `{path:, rl_version:, capabilities:}`. Unknown keys are silently ignored. `:capabilities` is only present in the returned hash when a `c=` entry existed — absent, not `[]`, when there wasn't one.
|
|
149
|
+
|
|
150
|
+
### `validate!(strings) → Array<String>`
|
|
151
|
+
|
|
152
|
+
Raises `RobotLab::Discovery::Error` if any single string exceeds `MAX_STRING_BYTES`, or if the total wire size (`sum of (1 + bytesize)` across all strings, accounting for each string's length-prefix byte) exceeds `MAX_TOTAL_BYTES`. Returns `strings` unchanged when valid. Called internally by `encode`; exposed publicly for callers who build TXT string arrays some other way.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## `RobotLab::Discovery::Constants` (module)
|
|
157
|
+
|
|
158
|
+
### `SERVICE_TYPE`
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
RobotLab::Discovery::Constants::SERVICE_TYPE # => "_robot-lab._tcp.local."
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The one DNS-SD service type every robot registers under, regardless of name or capabilities.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Getting Started
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Ruby 3.2+ (per the gemspec's `required_ruby_version`)
|
|
6
|
+
- A multicast-capable network interface — standard on any LAN. `zeroconf` is pure Ruby and implements mDNS itself over standard UDP multicast sockets; it does not wrap Bonjour, Avahi, or any system mDNS daemon, so there is nothing else to install.
|
|
7
|
+
- No dependency on core `robot_lab` — this gem works completely standalone. If `RobotLab` (core) happens to be loaded, `robot_lab-discovery` self-registers as an extension via `RobotLab.register_extension(:discovery, ...)`; if it isn't, nothing changes.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bundle add robot_lab-discovery
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or add to your `Gemfile` directly:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "robot_lab-discovery"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bundle install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Advertising a Robot
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "robot_lab/discovery"
|
|
31
|
+
|
|
32
|
+
adv = RobotLab::Discovery::Advertiser.new(
|
|
33
|
+
name: "headline", # mDNS instance name — no dots allowed
|
|
34
|
+
port: 9292,
|
|
35
|
+
path: "/headline", # stored in the TXT record
|
|
36
|
+
hostname: Socket.gethostname, # default; override for multi-homed hosts
|
|
37
|
+
capabilities: ["writing", "research"] # free-form taxonomy terms
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
adv.start # begins advertising in a background thread
|
|
41
|
+
# ... your server runs ...
|
|
42
|
+
adv.stop # sends an mDNS goodbye packet (TTL=0); removes it from peers' caches
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`start` spawns the actual `ZeroConf::Service` on a background `Thread` and returns `self` immediately — it does not block your server's main thread. `stop` joins that thread before returning, so it's safe to assume the service is fully torn down once `stop` returns.
|
|
46
|
+
|
|
47
|
+
Capability strings are free-form — see [How It Works — Capability Taxonomy](how_it_works.md#capability-taxonomy) for the (light) conventions worth following. Each one is normalized into a valid DNS label (`"Web Search"` → `"web-search"`) and registered as a DNS-SD subtype, so peers can browse for it directly without scanning every robot on the network.
|
|
48
|
+
|
|
49
|
+
## Discovering Robots
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require "robot_lab/discovery"
|
|
53
|
+
|
|
54
|
+
# All robots on the LAN
|
|
55
|
+
results = RobotLab::Discovery.browse(timeout: 3)
|
|
56
|
+
results.each { |r| puts "#{r.name}: #{r.url} caps=#{r.capabilities.join(', ')}" }
|
|
57
|
+
|
|
58
|
+
# Find one robot by name (polls until found or timeout)
|
|
59
|
+
result = RobotLab::Discovery.find("headline", timeout: 5)
|
|
60
|
+
result.url # => "http://my-server.local:9292/headline"
|
|
61
|
+
result.capabilities # => ["writing", "research"]
|
|
62
|
+
|
|
63
|
+
# Find all robots offering a specific capability
|
|
64
|
+
researchers = RobotLab::Discovery.find_by_capability("research", timeout: 3)
|
|
65
|
+
|
|
66
|
+
# List every capability type currently advertised on the LAN
|
|
67
|
+
RobotLab::Discovery.list_capabilities(timeout: 3)
|
|
68
|
+
# => ["analysis", "coding", "research", "writing"]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
All four are blocking calls — each one waits (up to `timeout` seconds) for mDNS responses to arrive over the network before returning. None of them should be called from a request thread that needs to stay responsive; see [Key Constraints](#key-constraints) below.
|
|
72
|
+
|
|
73
|
+
## Connecting After Discovery
|
|
74
|
+
|
|
75
|
+
`Result#url` is a plain URL string — wire it to whatever client you use:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
result = RobotLab::Discovery.find("headline")
|
|
79
|
+
|
|
80
|
+
# With robot_lab-a2a:
|
|
81
|
+
client = A2A.client(url: result.url)
|
|
82
|
+
client.send_task(message: A2A::Models::Message.user("Summarise today's news"))
|
|
83
|
+
|
|
84
|
+
# With Net::HTTP directly:
|
|
85
|
+
Net::HTTP.get(URI(result.url))
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Running the Bundled Example
|
|
89
|
+
|
|
90
|
+
`examples/01_basic_usage.rb` walks through the complete lifecycle end to end — registering three robots with different capabilities, browsing all of them, finding one by name, finding by capability, listing the capability inventory, then unregistering:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
bundle exec ruby examples/01_basic_usage.rb
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Both the advertisers and the browser run in the same process in that example purely for demo convenience — in production, each robot's own server process runs its own `Advertiser`, and any separate process (a dashboard, an orchestrator, another robot) does the browsing.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
bundle install
|
|
102
|
+
bundle exec rake test # run the test suite (Minitest::Reporters::SpecReporter output)
|
|
103
|
+
bin/console # IRB with the gem loaded
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Tests mock the `zeroconf` interactions (`ZeroConf.stub(:browse, ...)`) — no actual mDNS traffic is sent while running the suite.
|
|
107
|
+
|
|
108
|
+
## Key Constraints
|
|
109
|
+
|
|
110
|
+
- **mDNS is LAN-only.** It works within a single multicast subnet — it does not cross routers, VPN tunnels, or cloud network boundaries. There is no equivalent of this gem for discovery across separate networks.
|
|
111
|
+
- **`find` and `browse` can return stale results.** If a robot's process crashes without calling `Advertiser#stop`, its mDNS records are only removed once their TTL expires on peers' caches — there's no guaranteed instant removal in that failure path (only the graceful-shutdown goodbye packet from `stop` is instant).
|
|
112
|
+
- **`find` polls in a blocking loop.** It repeatedly calls `browse` internally until the named robot appears or `timeout` elapses — see [How It Works — `find(name, timeout:)`](how_it_works.md#findname-timeout). Don't call it from a thread (a web request handler, an event loop) that needs to stay responsive.
|
|
113
|
+
- **Capability content is capped.** All capabilities for one robot are joined into a single comma-separated TXT string capped at 253 bytes of content (`TxtRecord::MAX_CAPABILITIES_CONTENT`). Many short capability names comfortably fit; very long strings or a huge number of them will raise `RobotLab::Discovery::Error` at `Advertiser#start` (via `TxtRecord.encode`/`validate!`) — see [How It Works](how_it_works.md#the-txt-record-wire-format) for the exact limits.
|
|
114
|
+
- **Instance names may not contain dots.** `name:` becomes the mDNS instance name; DNS-SD instance names are dot-separated internally (`name._robot-lab._tcp.local.`), so a `name:` containing a literal `.` will produce a malformed or ambiguous service name.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# How It Works
|
|
2
|
+
|
|
3
|
+
## The mDNS/DNS-SD Model
|
|
4
|
+
|
|
5
|
+
This gem is a thin, purpose-built layer over two RFCs:
|
|
6
|
+
|
|
7
|
+
- **mDNS** ([RFC 6762](https://www.rfc-editor.org/rfc/rfc6762)) — DNS queries and responses sent over UDP multicast on the local subnet (`224.0.0.251:5353`), instead of to a unicast DNS server. Any host on the same LAN segment hears every query and response.
|
|
8
|
+
- **DNS-SD** ([RFC 6763](https://www.rfc-editor.org/rfc/rfc6763)) — a convention for naming *services* within DNS: a service type (`_robot-lab._tcp.local.`), an instance name, and a TXT record carrying arbitrary key/value metadata alongside the usual SRV record (which carries the target host and port).
|
|
9
|
+
|
|
10
|
+
`RobotLab::Discovery::Constants::SERVICE_TYPE` is `"_robot-lab._tcp.local."` — every robot advertised by this gem, regardless of name or capabilities, registers under that one DNS-SD service type. A browser listening for that type hears every robot on the LAN; capability-specific browsing (below) narrows that with DNS-SD *subtypes* instead of a second protocol.
|
|
11
|
+
|
|
12
|
+
The `zeroconf` gem (this gem's only runtime dependency) implements both RFCs itself in pure Ruby over a raw UDP socket — there is no Bonjour/Avahi/system daemon involved and nothing to install beyond the gem itself.
|
|
13
|
+
|
|
14
|
+
## Advertising: `Advertiser`
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
adv = RobotLab::Discovery::Advertiser.new(name:, port:, path:, hostname: Socket.gethostname, capabilities: [])
|
|
18
|
+
adv.start
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`start` builds a `ZeroConf::Service` for `SERVICE_TYPE` with:
|
|
22
|
+
|
|
23
|
+
- `instance_name:` — the `name:` you gave (becomes the DNS-SD instance name)
|
|
24
|
+
- `text:` — the TXT record, built by `TxtRecord.encode(path:, capabilities:)` (see below)
|
|
25
|
+
- `subtypes:` — one `"_#{dns_label(capability)}"` string per capability
|
|
26
|
+
|
|
27
|
+
...then spawns `@service.start` on a background `Thread` and returns `self` immediately, so `start` never blocks the caller. `stop` calls `@service.stop` (which sends the mDNS *goodbye* packet — a response with TTL=0 telling every listening peer to drop this record from its cache right away) and then `@thread.join`, so by the time `stop` returns, the advertising thread has fully exited. `started?` reports `@thread&.alive? || false`.
|
|
28
|
+
|
|
29
|
+
### Subtypes: How Capability Browsing Actually Works
|
|
30
|
+
|
|
31
|
+
DNS-SD subtypes are a real part of the spec, not something this gem invents: a service can register as belonging to `_robot-lab._tcp.local.` **and** to any number of `_<subtype>._sub._robot-lab._tcp.local.` names simultaneously. `Advertiser#start` registers one subtype per capability — a robot advertising `["writing", "research"]` shows up under:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
_robot-lab._tcp.local. (always)
|
|
35
|
+
_writing._sub._robot-lab._tcp.local.
|
|
36
|
+
_research._sub._robot-lab._tcp.local.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`Discovery.find_by_capability("research")` browses `_research._sub._robot-lab._tcp.local.` directly — the mDNS query itself only reaches robots registered under that subtype, so this is a real network-level filter, not a client-side filter over every robot's TXT record. `Discovery.browse`/`list_capabilities`, by contrast, browse the base `_robot-lab._tcp.local.` type and see every robot regardless of capability.
|
|
40
|
+
|
|
41
|
+
## The TXT Record Wire Format
|
|
42
|
+
|
|
43
|
+
`TxtRecord` encodes three possible fields into the DNS TXT record, using single-character keys to conserve UDP packet space:
|
|
44
|
+
|
|
45
|
+
| Wire key | Meaning | Long-form equivalent avoided |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `p` | `path` | `path=` (saves 3 bytes) |
|
|
48
|
+
| `v` | `rl_version` (the gem's `VERSION` at encode time) | `rl_version=` (saves 9 bytes) |
|
|
49
|
+
| `c` | `capabilities`, comma-joined | `capabilities=` (saves 11 bytes) |
|
|
50
|
+
|
|
51
|
+
`encode(path:, capabilities: [])` always includes `p=` and `v=`; `c=` is omitted entirely when `capabilities` is empty (not encoded as `c=`). `decode(strings)` reverses this, splitting each `"key=value"` string on the *first* `=` only (so a path containing `=` round-trips correctly — `"p=/foo=bar"` decodes to `path: "/foo=bar"`), and silently ignores any key it doesn't recognize (forward-compatible with future fields). `capabilities` is only present in the decoded hash when a `c=` entry existed at all — `decode(["p=/x"])` returns a hash with no `:capabilities` key, not `capabilities: []`; `Browser#parse_response` is what supplies the `[]` default via `txt.fetch(:capabilities, [])` when building a `Result`.
|
|
52
|
+
|
|
53
|
+
### Size Limits, and Why They Exist
|
|
54
|
+
|
|
55
|
+
Two limits are enforced by `TxtRecord.validate!`, called from inside `encode`:
|
|
56
|
+
|
|
57
|
+
1. **Per-string: 255 bytes** (`MAX_STRING_BYTES`). This isn't a design choice — it's RFC 1035 §3.3.14: every string inside a TXT record is length-prefixed with a single byte, so no individual string can exceed 255 bytes no matter what. Since capabilities all share one `c=...` string, `MAX_CAPABILITIES_CONTENT` (`255 - "c".bytesize - "=".bytesize` = **253** bytes) is the real budget for however many comma-separated capability names you pack in.
|
|
58
|
+
2. **Total wire size: 1300 bytes** (`MAX_TOTAL_BYTES`). This one *is* a design choice, derived from the physical network: mDNS runs over UDP, and a standard Ethernet MTU (1500 bytes) minus IP/UDP/DNS header overhead leaves roughly 1472 bytes for the whole DNS payload. After the SRV and A/AAAA records that accompany every service announcement also claim space in that same packet, keeping the TXT record itself under 1300 bytes (wire size = 1 length-prefix byte + content bytes, summed across every string) leaves enough headroom that the whole announcement stays inside a single unfragmented UDP packet. `encode` alone can never actually reach this second limit — three strings, each capped at 255 bytes, tops out well under 1300 — so this check exists to protect callers who build TXT string arrays some other way and hand them to `validate!` directly, not a realistic failure mode of `encode` itself.
|
|
59
|
+
|
|
60
|
+
Both violations raise `RobotLab::Discovery::Error` with a message naming the exact byte count, at `Advertiser#start` time (since that's when `TxtRecord.encode` runs) — not at `Advertiser.new` time, since `capabilities:` is only assigned, not encoded, in the constructor.
|
|
61
|
+
|
|
62
|
+
## Discovering: `Browser`
|
|
63
|
+
|
|
64
|
+
`Browser` is a module of class methods — there's no instance to hold, since browsing is stateless per call.
|
|
65
|
+
|
|
66
|
+
#### `browse(timeout:)`
|
|
67
|
+
|
|
68
|
+
Calls `ZeroConf.browse(SERVICE_TYPE, timeout:)`, which blocks for up to `timeout` seconds collecting raw mDNS responses, then maps each one through the private `parse_response` and drops any that come back `nil` (`filter_map`), then deduplicates by `name` (`uniq(&:name)` — a robot that responds more than once during the browse window, which is normal for mDNS, only appears once in the result).
|
|
69
|
+
|
|
70
|
+
#### `find(name, timeout:)`
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
deadline = Time.now + timeout
|
|
74
|
+
loop do
|
|
75
|
+
remaining = deadline - Time.now
|
|
76
|
+
return nil if remaining <= 0
|
|
77
|
+
result = browse(timeout: [remaining, 1].min).find { |r| r.name == name }
|
|
78
|
+
return result if result
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This is a **polling loop**, not a single `browse` call with a longer timeout: it repeatedly calls `browse` in increments of at most 1 second (`[remaining, 1].min`), checking after each one whether the named robot showed up, until either it's found or the overall `timeout` has elapsed. The 1-second cap keeps each individual `browse` responsive rather than committing the whole remaining budget to one blocking call; the practical effect is that `find` can return as soon as the target robot answers, rather than always waiting out the full `timeout`.
|
|
83
|
+
|
|
84
|
+
#### `find_by_capability(capability, timeout:)`
|
|
85
|
+
|
|
86
|
+
Normalizes `capability` via `RobotLab::Discovery.dns_label`, browses `"_#{label}._sub.#{SERVICE_TYPE}"` — the DNS-SD subtype, not the base service type — and otherwise behaves exactly like `browse` (parse, filter, dedupe by name).
|
|
87
|
+
|
|
88
|
+
#### `list_capabilities(timeout:)`
|
|
89
|
+
|
|
90
|
+
`browse(timeout:).flat_map(&:capabilities).uniq.sort` — every capability string across every currently-visible robot, deduplicated and alphabetically sorted.
|
|
91
|
+
|
|
92
|
+
#### `parse_response(response)` *(private)*
|
|
93
|
+
|
|
94
|
+
Given a raw `Resolv::DNS::Message`-like response, walks `response.answer + response.additional` looking for an `SRV` record (supplies `port`, `hostname` — with the trailing `.` FQDN dot stripped — and the instance name, taken from the first label of the resource name) and a `TXT` record (decoded via `TxtRecord.decode`). If either the SRV data or a `path` from the TXT data is missing, it returns `nil` rather than raising — a malformed or partial mDNS response is simply skipped, not an error condition, which is why `browse` uses `filter_map` rather than `map`.
|
|
95
|
+
|
|
96
|
+
## `RobotLab::Discovery::Result`
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
Result = Data.define(:name, :hostname, :port, :path, :capabilities) do
|
|
100
|
+
def url = "http://#{hostname}:#{port}#{path}"
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
An immutable value object (Ruby's `Data.define`, not a mutable `Struct` or hash) — every `Result` a `browse`/`find`/`find_by_capability` call returns is a fresh, frozen snapshot; there's no way to accidentally mutate one and have it silently affect a cache. `url` always assumes plain `http://` — this gem has no notion of TLS and doesn't encode a scheme in the TXT record; if a robot actually serves over HTTPS, build the URL yourself from the other fields rather than using `#url` directly.
|
|
105
|
+
|
|
106
|
+
## `dns_label`
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
def self.dns_label(capability)
|
|
110
|
+
capability.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Downcases, collapses every run of non-alphanumeric characters to a single hyphen, then strips any leading/trailing hyphen left over. `"Web Search"` → `"web-search"`; `"NLP/Analysis"` → `"nlp-analysis"`; `"Web Search / NLP"` → `"web-search-nlp"` (the `/` and surrounding spaces all collapse into the hyphens already produced by the spaces around them). This is what turns a free-form capability string into something legal to use as a DNS label component in a subtype name — DNS labels can't contain arbitrary punctuation, spaces, or mixed case in a way every resolver treats consistently.
|
|
115
|
+
|
|
116
|
+
## Capability Taxonomy
|
|
117
|
+
|
|
118
|
+
Capabilities are entirely free-form strings — there is no enforced registry or fixed vocabulary anywhere in this gem. The convention (not an enforced rule) is short, lowercase, hyphen-separated terms describing *what* a robot does, not *how* it works internally — e.g. `research`, `analysis`, `writing`, `coding`, `vision`, `memory`, `tool-use`, `orchestration`, `web-search`, `summarisation`, `classification`. A robot can advertise any number of them; each becomes its own DNS-SD subtype (see above), so clients browsing by capability only ever hear from robots that actually advertise it — no client-side filtering of the full robot list is needed.
|
|
119
|
+
|
|
120
|
+
## Errors
|
|
121
|
+
|
|
122
|
+
`RobotLab::Discovery::Error` (a plain `StandardError` subclass) is raised in exactly one place in this gem: `TxtRecord.validate!`, when either the per-string or total wire-size limit is exceeded (see above). There's no other error class in the gem — `Advertiser`/`Browser` let any underlying `zeroconf`/`Resolv`/socket error propagate as-is rather than wrapping it.
|
|
123
|
+
|
|
124
|
+
## Key Constraints (and Why)
|
|
125
|
+
|
|
126
|
+
These follow directly from the mechanics above, not from arbitrary gem-level restrictions:
|
|
127
|
+
|
|
128
|
+
- **LAN-only, by protocol, not by choice.** mDNS multicast packets don't cross routers (that's what makes them "local" — RFC 6762 explicitly scopes them to the local link). There is no configuration in this gem that extends reach beyond a single multicast subnet; a VPN, a different VLAN, or a cloud network boundary will not see these announcements at all.
|
|
129
|
+
- **Stale results after an ungraceful crash.** `Advertiser#stop` sends an explicit goodbye packet that tells peers to evict the record immediately. A process that dies without calling `stop` (a kill -9, a crash) never sends that packet — its record simply sits in every peer's cache until that record's normal mDNS TTL expires on its own. `browse`/`find` have no way to distinguish "still running" from "crashed, TTL not yet expired" — a caller that needs liveness guarantees beyond "recently advertised" needs a mechanism this gem doesn't provide (e.g. an application-level heartbeat over whatever transport the discovered URL leads to).
|
|
130
|
+
- **`find` blocks the calling thread.** It's a real polling loop (see above), not instant — even a successful lookup can take up to ~1 second (one polling increment) after the robot actually answers, and an unsuccessful one blocks for the *entire* `timeout`. Never call it from a thread that has to stay responsive (a web request handler, an event loop's main thread) without moving it to a background thread or job yourself.
|
|
131
|
+
- **Capability content is capped by the RFC 1035 per-string limit**, not an arbitrary choice this gem made — see [Size Limits](#size-limits-and-why-they-exist) above for the exact numbers and where they come from.
|
data/docs/index.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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](https://www.rfc-editor.org/rfc/rfc6762)) and find each other without any central registry, hardcoded URL, or configuration file. Each robot announces a name, an HTTP path, and a free-form set of *capability* tags; other robots (or any process on the LAN) can browse for all of them, look one up by name, or filter by capability.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "robot_lab/discovery"
|
|
9
|
+
|
|
10
|
+
# A robot announces itself:
|
|
11
|
+
adv = RobotLab::Discovery::Advertiser.new(
|
|
12
|
+
name: "headline", port: 9292, path: "/headline",
|
|
13
|
+
capabilities: ["writing", "research"]
|
|
14
|
+
)
|
|
15
|
+
adv.start
|
|
16
|
+
|
|
17
|
+
# Anyone on the LAN can find it:
|
|
18
|
+
result = RobotLab::Discovery.find("headline", timeout: 5)
|
|
19
|
+
result.url # => "http://my-server.local:9292/headline"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This gem augments `robot_lab-a2a` but has **no dependency on it, and no dependency on core `robot_lab` either** — its only runtime dependency is the pure-Ruby [`zeroconf`](https://rubygems.org/gems/zeroconf) gem. Discovery finds you a URL; you decide what protocol to speak to it (A2A, plain HTTP, anything else).
|
|
23
|
+
|
|
24
|
+
## Navigation
|
|
25
|
+
|
|
26
|
+
- [Getting Started](getting_started.md) — installation, advertising a robot, discovering robots, the bundled example
|
|
27
|
+
- [How It Works](how_it_works.md) — the mDNS/DNS-SD model this gem builds on, the TXT record wire format, capability subtypes, and the constraints that follow from all of it
|
|
28
|
+
- [API Reference](api_reference.md) — every public class and method
|
|
29
|
+
|
|
30
|
+
## At a Glance
|
|
31
|
+
|
|
32
|
+
| | |
|
|
33
|
+
|---|---|
|
|
34
|
+
| **Protocol** | Multicast DNS / DNS-SD (RFC 6762 / RFC 6763), pure Ruby — no Bonjour/Avahi/system daemon |
|
|
35
|
+
| **Service type** | `_robot-lab._tcp.local.` |
|
|
36
|
+
| **Advertise** | `RobotLab::Discovery::Advertiser.new(name:, port:, path:, capabilities:).start` |
|
|
37
|
+
| **Discover** | `RobotLab::Discovery.browse` / `.find` / `.find_by_capability` / `.list_capabilities` |
|
|
38
|
+
| **Result** | `RobotLab::Discovery::Result` — `name`, `hostname`, `port`, `path`, `capabilities`, `#url` |
|
|
39
|
+
| **Scope** | LAN-only — a single multicast subnet, not routable across the internet |
|
|
40
|
+
| **Runtime dependency** | `zeroconf ~> 1.0` only |
|
|
41
|
+
| **Optional integration** | Self-registers via `RobotLab.register_extension(:discovery, ...)` when core `robot_lab` is loaded — otherwise usable completely standalone |
|
|
42
|
+
|
|
43
|
+
## Relationship to Other RobotLab Gems
|
|
44
|
+
|
|
45
|
+
| Gem | Concern |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `robot_lab` | Core robots, networks, memory |
|
|
48
|
+
| `robot_lab-a2a` | The A2A HTTP+SSE transport protocol *between* robots |
|
|
49
|
+
| `robot_lab-discovery` | **Where** the robots are and **what they can do** — addressing and capability taxonomy, not transport |
|
|
50
|
+
|
|
51
|
+
A typical pairing: use `robot_lab-discovery` to find a robot's URL on the LAN, then hand that URL to `robot_lab-a2a` (or plain `Net::HTTP`, or anything else) to actually talk to it.
|
|
52
|
+
|
|
53
|
+
## Links
|
|
54
|
+
|
|
55
|
+
- [RobotLab Core](https://github.com/MadBomber/robot_lab)
|
|
56
|
+
- [RubyGems](https://rubygems.org/gems/robot_lab-discovery)
|
|
57
|
+
- [GitHub](https://github.com/MadBomber/robot_lab-discovery)
|
|
58
|
+
- [Changelog](https://github.com/MadBomber/robot_lab-discovery/blob/main/CHANGELOG.md)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/01_basic_usage.rb
|
|
5
|
+
#
|
|
6
|
+
# Demonstrates the complete lifecycle of robot service discovery on a local
|
|
7
|
+
# network using multicast DNS (mDNS / RFC 6762):
|
|
8
|
+
#
|
|
9
|
+
# Register — Advertiser announces robots under _robot-lab._tcp.local.,
|
|
10
|
+
# encoding each robot's HTTP path and capability taxonomy in its
|
|
11
|
+
# TXT record and as DNS-SD subtypes.
|
|
12
|
+
#
|
|
13
|
+
# Browse — Discovery.browse() returns every robot on the LAN, with its
|
|
14
|
+
# name, hostname, port, path, capabilities, and url.
|
|
15
|
+
#
|
|
16
|
+
# By name — Discovery.find() targets a single robot by instance name.
|
|
17
|
+
#
|
|
18
|
+
# By capability — Discovery.find_by_capability() browses a DNS-SD subtype
|
|
19
|
+
# (e.g. _research._sub._robot-lab._tcp.local.) and returns only
|
|
20
|
+
# robots that advertise that capability.
|
|
21
|
+
#
|
|
22
|
+
# Inventory — Discovery.list_capabilities() collects every capability term
|
|
23
|
+
# advertised on the LAN and returns them sorted and deduped.
|
|
24
|
+
#
|
|
25
|
+
# Unregister — Stopping an Advertiser sends an mDNS goodbye packet (TTL=0),
|
|
26
|
+
# removing the service from peers' caches immediately.
|
|
27
|
+
#
|
|
28
|
+
# Prerequisites:
|
|
29
|
+
# A multicast-capable network interface (standard on any LAN).
|
|
30
|
+
# The zeroconf gem is pure Ruby — no Bonjour, Avahi, or system daemon needed.
|
|
31
|
+
#
|
|
32
|
+
# Note: both advertisers and browser run in the same process here for demo
|
|
33
|
+
# purposes. In production each robot's server process runs its own Advertiser.
|
|
34
|
+
|
|
35
|
+
require_relative "common"
|
|
36
|
+
|
|
37
|
+
ROBOT_PORT = 9292
|
|
38
|
+
BROWSE_TIMEOUT = 3 # seconds to listen for mDNS responses
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# 1. Register robots on the local network
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
puts "=== Registering robots ==="
|
|
44
|
+
|
|
45
|
+
robots = [
|
|
46
|
+
{ name: "headline", path: "/headline", capabilities: ["writing", "research"] },
|
|
47
|
+
{ name: "tags", path: "/tags", capabilities: ["analysis"] },
|
|
48
|
+
{ name: "market-analyst",path: "/market-analyst", capabilities: ["research", "analysis"] },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
advertisers = robots.map do |r|
|
|
52
|
+
adv = RobotLab::Discovery::Advertiser.new(
|
|
53
|
+
name: r[:name],
|
|
54
|
+
port: ROBOT_PORT,
|
|
55
|
+
path: r[:path],
|
|
56
|
+
hostname: Socket.gethostname,
|
|
57
|
+
capabilities: r[:capabilities]
|
|
58
|
+
)
|
|
59
|
+
adv.start
|
|
60
|
+
puts " [+] #{r[:name]} (#{r[:capabilities].join(", ")}) → #{adv.hostname}.local:#{ROBOT_PORT}#{r[:path]}"
|
|
61
|
+
adv
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
puts
|
|
65
|
+
|
|
66
|
+
# Allow mDNS announcements to propagate across the multicast group before
|
|
67
|
+
# browsing. The zeroconf Service sends its announcement immediately on start,
|
|
68
|
+
# but peers may need a moment to process it.
|
|
69
|
+
sleep 1
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# 2. Browse all robots on the local network
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
puts "=== Browsing local network (timeout: #{BROWSE_TIMEOUT}s) ==="
|
|
75
|
+
|
|
76
|
+
results = RobotLab::Discovery.browse(timeout: BROWSE_TIMEOUT)
|
|
77
|
+
|
|
78
|
+
if results.empty?
|
|
79
|
+
puts " No robots found."
|
|
80
|
+
else
|
|
81
|
+
puts " Found #{results.size} robot(s):\n\n"
|
|
82
|
+
results.each do |r|
|
|
83
|
+
caps = r.capabilities.empty? ? "(none)" : r.capabilities.join(", ")
|
|
84
|
+
puts <<~RESULT
|
|
85
|
+
Name : #{r.name}
|
|
86
|
+
Hostname : #{r.hostname}
|
|
87
|
+
Port : #{r.port}
|
|
88
|
+
Path : #{r.path}
|
|
89
|
+
Capabilities : #{caps}
|
|
90
|
+
URL : #{r.url}
|
|
91
|
+
|
|
92
|
+
RESULT
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# 3. Find a specific robot by name
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
puts "=== Finding 'headline' by name ==="
|
|
100
|
+
|
|
101
|
+
result = RobotLab::Discovery.find("headline", timeout: BROWSE_TIMEOUT)
|
|
102
|
+
|
|
103
|
+
if result
|
|
104
|
+
puts " Found: #{result.url}"
|
|
105
|
+
puts " Capabilities: #{result.capabilities.join(", ")}"
|
|
106
|
+
else
|
|
107
|
+
puts " 'headline' not found within #{BROWSE_TIMEOUT}s."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
puts
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# 4. Find robots by capability
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
puts "=== Finding all 'research' robots ==="
|
|
116
|
+
|
|
117
|
+
researchers = RobotLab::Discovery.find_by_capability("research", timeout: BROWSE_TIMEOUT)
|
|
118
|
+
|
|
119
|
+
if researchers.empty?
|
|
120
|
+
puts " No research robots found."
|
|
121
|
+
else
|
|
122
|
+
puts " Found #{researchers.size} research robot(s):"
|
|
123
|
+
researchers.each { |r| puts " #{r.name} — #{r.url}" }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
puts
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# 5. List all capability types available on the LAN
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
puts "=== Available capability types ==="
|
|
132
|
+
|
|
133
|
+
caps = RobotLab::Discovery.list_capabilities(timeout: BROWSE_TIMEOUT)
|
|
134
|
+
|
|
135
|
+
if caps.empty?
|
|
136
|
+
puts " None advertised."
|
|
137
|
+
else
|
|
138
|
+
caps.each { |c| puts " #{c}" }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
puts
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# 6. Unregister — send mDNS goodbye packets
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
puts "=== Unregistering robots ==="
|
|
147
|
+
|
|
148
|
+
advertisers.each do |adv|
|
|
149
|
+
adv.stop
|
|
150
|
+
puts " [-] #{adv.name} unregistered"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
puts
|
|
154
|
+
puts "Done."
|
data/examples/common.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# robot_lab-discovery/examples/common.rb
|
|
4
|
+
#
|
|
5
|
+
# Loaded by every example. Puts the gem's lib/ on the path so examples run
|
|
6
|
+
# from a checkout without a gem install.
|
|
7
|
+
|
|
8
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
9
|
+
require "robot_lab/discovery"
|