ruby_zmq_framework 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f85c8cb84cb6cfb2fc84e4c03d5fd0e3f00fbf0627a814cab9edbb4a3ca82434
4
- data.tar.gz: 02dcec88c184e898f094a292ba3309f3a7a8dc3d477335b7314654681e681d59
3
+ metadata.gz: 5463eeb0f6933758bd516406c8fc45dd1d366089337244760247a87376639174
4
+ data.tar.gz: 90ebc27c4e1635883fc246a3d188ba5cf7c67233a9dc5e219b964cc1e240ff1d
5
5
  SHA512:
6
- metadata.gz: f3e2d7ece26853813dccd26d68d9924ab8028cc65d69610827690b9741c67f4910097c137523d3b3bd47cc31eb0cc5f4ef0b83ddf73ac736e2bfcb11585cb6f6
7
- data.tar.gz: 1f4c6eb92e5d93ba39e07e2863f25fd1e1f6843aac60a317836bb517e7b731b8dd219b38e54f7529113dc2712366c67aa9e09d0eaea0e0aa95375ba7eac6d1df
6
+ metadata.gz: a927b2df81d865d478db868c47dccb12674cf775d7211f02d4d2068b0c52fc979a4cb170b87906a2b988091df27bdc8ca7201a203b737346e2ceb529eab748bb
7
+ data.tar.gz: 4a030f0e3bb7715ec8d42de84e14e8a6fe1be1a2148dd3ec0831d6bde0bcdede4a0530306a133b44f9ab4de7138bc39d24d9b00bc616ba7daec00153abbb84d8
data/CHANGELOG.md CHANGED
@@ -49,6 +49,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
49
49
  - ZeroMQ return codes are checked; failures raise
50
50
  `RubyZmqFramework::Error` instead of passing silently.
51
51
 
52
+ ## [0.1.1] - 2026-07-24
53
+
54
+ ### Fixed
55
+ - `bin/flowctl` is now actually packaged with the gem (registered as the
56
+ `flowctl` executable) instead of only existing in the git checkout —
57
+ previously a project that merely depended on the gem had no way to run
58
+ the Quick Start despite the README instructing `bundle exec bin/flowctl`.
59
+ `PROTOCOL.md` is shipped alongside it for the same reason.
60
+ - `bin/flowctl` resolved its nodes' working directory from its own
61
+ install location (`__dir__`) rather than the manifest it was given, so
62
+ a `flow.yml` outside this repo would spawn nodes that couldn't find
63
+ their own relative `cmd` paths. It now `chdir`s to the manifest's
64
+ directory.
65
+
52
66
  ## [0.1.0] - 2026-07-21
53
67
 
54
68
  - Initial version: `ZeroMQBus`, `StrictContract`, `FrameworkModule`
data/PROTOCOL.md ADDED
@@ -0,0 +1,93 @@
1
+ # Bus Protocol
2
+
3
+ This one page is the entire contract. A node written in any language that
4
+ follows it is a full citizen of the flow — no framework code required.
5
+ It is deliberately small enough to paste into an LLM prompt as the
6
+ complete context for writing a new node.
7
+
8
+ ## Transport
9
+
10
+ ZeroMQ PUB/SUB over TCP. Every node owns exactly one PUB socket (bound to
11
+ its own port, where it publishes everything) and one SUB socket
12
+ (connected to each peer's PUB port, subscribed to `""` — all topics —
13
+ with filtering done after receipt).
14
+
15
+ ## Wire format
16
+
17
+ Every message is a two-frame ZeroMQ multipart message:
18
+
19
+ | frame | content |
20
+ |-------|--------------------------------------------|
21
+ | 0 | topic — UTF-8 string, `lower_snake_case` |
22
+ | 1 | payload — a JSON **object** (`{...}`), UTF-8 |
23
+
24
+ Anything else (single frames, non-JSON payloads) is dropped by
25
+ well-behaved receivers with a warning. Never crash on a bad message.
26
+
27
+ ## Process environment (set by the launcher)
28
+
29
+ A node learns its wiring from four environment variables and must not
30
+ hardcode any topology:
31
+
32
+ | variable | meaning | example |
33
+ |------------------|----------------------------------------------------|---------------------------------|
34
+ | `BUS_PORT` | port to bind the PUB socket on (`0`/unset = any) | `5555` |
35
+ | `BUS_PEERS` | comma-separated `host:port` list to connect SUB to | `127.0.0.1:5556,127.0.0.1:5557` |
36
+ | `BUS_SUBSCRIBES` | comma-separated topics this node should act on | `engine_data,heartbeat` |
37
+ | `NODE_NAME` | this node's identity, used in heartbeats | `ecu` |
38
+
39
+ Unset variables mean: bind any free port, no peers, no subscriptions —
40
+ the node must still start (standalone mode).
41
+
42
+ ## Heartbeat
43
+
44
+ Every node publishes on topic `heartbeat` every **5 seconds**:
45
+
46
+ ```json
47
+ { "node_name": "<NODE_NAME>", "status": "ok", "timestamp": 1784796766 }
48
+ ```
49
+
50
+ `timestamp` is Unix seconds. A node that stops heartbeating is presumed
51
+ dead; there is no other liveness mechanism.
52
+
53
+ ## Conventions
54
+
55
+ - Payloads are always JSON objects, never bare arrays/scalars.
56
+ - Delivery is fire-and-forget: no acks, no replay. A slow consumer loses
57
+ old messages (ZeroMQ drops at the high-water mark). Design for
58
+ latest-value-wins.
59
+ - PUB/SUB connections take ~a few hundred ms to establish ("slow
60
+ joiner"): wait ~1s after startup before your first meaningful publish,
61
+ or design so early messages are harmless to lose.
62
+ - Request/response is done with a topic pair by convention, e.g. publish
63
+ `request_global_state` → someone publishes `global_state_snapshot`.
64
+
65
+ ## Minimal node, any language (Python shown)
66
+
67
+ ```python
68
+ import json, os, time, threading, zmq
69
+
70
+ ctx = zmq.Context()
71
+ pub = ctx.socket(zmq.PUB); pub.bind(f"tcp://127.0.0.1:{os.environ.get('BUS_PORT', '*')}")
72
+ sub = ctx.socket(zmq.SUB); sub.setsockopt_string(zmq.SUBSCRIBE, "")
73
+ for peer in filter(None, os.environ.get("BUS_PEERS", "").split(",")):
74
+ sub.connect(f"tcp://{peer}")
75
+
76
+ name = os.environ.get("NODE_NAME", "python-node")
77
+ topics = set(filter(None, os.environ.get("BUS_SUBSCRIBES", "").split(",")))
78
+
79
+ def heartbeat():
80
+ while True:
81
+ pub.send_multipart([b"heartbeat", json.dumps(
82
+ {"node_name": name, "status": "ok", "timestamp": int(time.time())}).encode()])
83
+ time.sleep(5)
84
+ threading.Thread(target=heartbeat, daemon=True).start()
85
+
86
+ while True:
87
+ topic, payload = sub.recv_multipart()
88
+ if topic.decode() in topics:
89
+ handle(topic.decode(), json.loads(payload)) # your logic here
90
+ ```
91
+
92
+ Add the node to `flow.yml` with its `cmd`, `publishes`, and `subscribes`,
93
+ and `bin/flowctl` wires it in.
data/bin/flowctl ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env ruby
2
+ # Runs a flow manifest: assigns each node a free port, computes the peer
3
+ # wiring, spawns every node with that wiring in its environment, and
4
+ # streams their output with a [name] prefix. Ctrl-C stops everything.
5
+ #
6
+ # bundle exec bin/flowctl # runs ./flow.yml
7
+ # bundle exec bin/flowctl other.yml
8
+ # bundle exec bin/flowctl --plan # print computed wiring, run nothing
9
+
10
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
11
+ require 'ruby_zmq_framework'
12
+ require 'socket'
13
+ require 'optparse'
14
+
15
+ plan_only = false
16
+ OptionParser.new do |opts|
17
+ opts.banner = 'Usage: bin/flowctl [--plan] [flow.yml]'
18
+ opts.on('--plan', 'Print the computed wiring and exit') { plan_only = true }
19
+ end.parse!
20
+
21
+ manifest = ARGV.first || 'flow.yml'
22
+ manifest_path = File.expand_path(manifest, Dir.pwd)
23
+ root = File.dirname(manifest_path)
24
+ flow = RubyZmqFramework::Flow.load(manifest_path)
25
+
26
+ def free_port
27
+ server = TCPServer.new('127.0.0.1', 0)
28
+ port = server.addr[1]
29
+ server.close
30
+ port
31
+ end
32
+
33
+ ports = flow.nodes.to_h { |node| [node.name, free_port] }
34
+ wiring = flow.wiring(ports)
35
+
36
+ if plan_only
37
+ wiring.each do |name, env|
38
+ puts name
39
+ env.each { |k, v| puts " #{k}=#{v}" }
40
+ end
41
+ exit
42
+ end
43
+
44
+ children = {}
45
+ flow.nodes.each do |node|
46
+ reader, writer = IO.pipe
47
+ pid = spawn(wiring[node.name], node.cmd, out: writer, err: writer, chdir: root)
48
+ writer.close
49
+ children[pid] = node.name
50
+
51
+ Thread.new do
52
+ reader.each_line { |line| puts "[#{node.name}] #{line}" }
53
+ end
54
+ end
55
+
56
+ puts "[flowctl] started #{children.size} nodes: #{children.values.join(', ')}"
57
+
58
+ begin
59
+ until children.empty?
60
+ pid = Process.wait
61
+ puts "[flowctl] #{children.delete(pid)} exited with status #{$?.exitstatus.inspect}"
62
+ end
63
+ puts '[flowctl] all nodes exited'
64
+ rescue Interrupt
65
+ puts "\n[flowctl] shutting down #{children.size} nodes"
66
+ children.each_key do |pid|
67
+ Process.kill('TERM', pid)
68
+ rescue Errno::ESRCH
69
+ nil
70
+ end
71
+ children.each_key do |pid|
72
+ Process.wait(pid)
73
+ rescue Errno::ECHILD
74
+ nil
75
+ end
76
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyZmqFramework
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_zmq_framework
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Daniel
@@ -58,13 +58,16 @@ description: 'Blackbox node processes wired together by pub/sub topics: the grap
58
58
  transport with strict runtime module contracts.'
59
59
  email:
60
60
  - paulgdan@gmail.com
61
- executables: []
61
+ executables:
62
+ - flowctl
62
63
  extensions: []
63
64
  extra_rdoc_files: []
64
65
  files:
65
66
  - CHANGELOG.md
66
67
  - LICENSE.txt
68
+ - PROTOCOL.md
67
69
  - README.md
70
+ - bin/flowctl
68
71
  - lib/ruby_zmq_framework.rb
69
72
  - lib/ruby_zmq_framework/can_bridge.rb
70
73
  - lib/ruby_zmq_framework/flow.rb