asterism 0.9.0 → 0.11.0
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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +52 -2
- data/lib/asterism/cli/complete.rb +2 -1
- data/lib/asterism/cli/mcp.rb +56 -1
- data/lib/asterism/cli/nouns.rb +97 -0
- data/lib/asterism/cli/values.rb +216 -0
- data/lib/asterism/cli.rb +16 -2
- data/lib/asterism/version.rb +1 -1
- metadata +5 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3f9045afdf176405f969bbf97ec703a00f6dc97b777224dfe71d449ebfcda29
|
|
4
|
+
data.tar.gz: '0748cc260662719332fa6a17030577ceb55ca53d137a9723ad100214c0518d72'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: afc211c874d21e5d73bbb94d0e5bd870bd8aa3d840ccd57f7aaf072d9ecebb0bae72abd7a9d3e9fdb742498cbb3eb3220fa0a35fd213a9ce727f1e65ca8b738c
|
|
7
|
+
data.tar.gz: e12f369efedb87918e86d1eb70346e0da7404d08f5b9fdb37b8ce244be3bc90c85cabf9e9d548a928ad48f683e26574a5062eaf55cba9167361cbe05735301ab
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,39 @@ the earlier ones are only in this repository. From 0.4.0 the two Zenoh
|
|
|
6
6
|
bindings (asterism-zenoh, picoruby-asterism-zenoh) carry the same minor
|
|
7
7
|
version (a patch release of one may come without the other).
|
|
8
8
|
|
|
9
|
+
## Unreleased
|
|
10
|
+
|
|
11
|
+
## 0.11.0
|
|
12
|
+
|
|
13
|
+
- Needs CRuby 3.4 or newer (`required_ruby_version >= 3.4`; 3.2 is past its end of life). CI runs Ruby 3.4 and 4.0.
|
|
14
|
+
|
|
15
|
+
## 0.10.0
|
|
16
|
+
|
|
17
|
+
The command line and its MCP bridge only; the library and the wire are as
|
|
18
|
+
in 0.9.0.
|
|
19
|
+
|
|
20
|
+
- `asterism mcp` has a tool `asterism_values`: the latest value of each
|
|
21
|
+
key the applications put on (`asterism/<node>/<app>/<object>/<more>`,
|
|
22
|
+
e.g. a game table's state), as the server received them since it
|
|
23
|
+
started (it subscribes to `asterism/**` from the start). Arguments
|
|
24
|
+
`pattern` (`*`, `**`; default every key) and `wait` (seconds, at most
|
|
25
|
+
30: for the first put that matches when none has come yet). Each value
|
|
26
|
+
has its time, its age, its size and whether its application is still on
|
|
27
|
+
the network (`app`: `alive` / `gone` / `restarted`). Limits: 1000 keys
|
|
28
|
+
and 16 MiB kept (the oldest forgotten first), a put over 256 KiB as its
|
|
29
|
+
size only, a value's JSON cut at 8192 bytes in an answer
|
|
30
|
+
(`truncated`), 100 keys an answer (`more`). A viewer's certificate is
|
|
31
|
+
enough. README "MCP".
|
|
32
|
+
- `asterism object get PATH [KEY]`: the next value the application puts
|
|
33
|
+
there (`--timeout`, 5 s). `asterism object echo PATH [KEY]`: the values
|
|
34
|
+
as they come (the text: a key's value whole, then what changed;
|
|
35
|
+
`--raw`, `--once`, `--duration`). `-o json` / `-o yaml`, and Tab
|
|
36
|
+
completion of `PATH`. Both say so when nothing came (not readable, or
|
|
37
|
+
not changed).
|
|
38
|
+
- The other tools, commands and their output (`--json` too) are as they
|
|
39
|
+
were; `asterism --help`, `asterism object` and the MCP server's
|
|
40
|
+
instructions mention the new ones.
|
|
41
|
+
|
|
9
42
|
## 0.9.0
|
|
10
43
|
|
|
11
44
|
The command line only; the library and the wire are as in 0.8.0 (0.8.1
|
data/README.md
CHANGED
|
@@ -85,7 +85,7 @@ time is taken, on both APIs and every Ruby; giving one twice raises
|
|
|
85
85
|
|
|
86
86
|
## Using it
|
|
87
87
|
|
|
88
|
-
CRuby (needs CRuby 3.
|
|
88
|
+
CRuby (needs CRuby 3.4+ and a C compiler):
|
|
89
89
|
|
|
90
90
|
```
|
|
91
91
|
gem install asterism # also installs asterism-zenoh (>= 0.4.1, < 1.0) and msgpack
|
|
@@ -101,7 +101,7 @@ build but not yet tested. To install from the repositories instead:
|
|
|
101
101
|
|
|
102
102
|
```
|
|
103
103
|
(cd ../asterism-zenoh && rake gem) && rake gem
|
|
104
|
-
gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.
|
|
104
|
+
gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.10.0.gem
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
From the working trees, without installing:
|
|
@@ -427,6 +427,8 @@ asterism app info games/games # its status and its objects
|
|
|
427
427
|
asterism object list 'games/games/*' # node/app/object, a line each
|
|
428
428
|
asterism object info games/games/othello1 # its description and its methods
|
|
429
429
|
asterism object find join # the objects that have join (* as a wildcard)
|
|
430
|
+
asterism object get games/games/othello1 state # the next value the application puts there
|
|
431
|
+
asterism object echo games/games/othello1 state # the values as they come (what changed)
|
|
430
432
|
asterism status get games/games # the status now
|
|
431
433
|
asterism status echo games/games # the status, then each change (--once: now, once)
|
|
432
434
|
asterism status hz games/games # how often it changes
|
|
@@ -553,6 +555,21 @@ $ asterism watch
|
|
|
553
555
|
only what changed: `a.b 0→1`, `+a.c=…` (a key came), `-a.d` (a key
|
|
554
556
|
went). `--timeout` (2.0 s) is each get's and call's limit, `--settle`
|
|
555
557
|
(1.5 s) how long it waits for the tokens after connecting.
|
|
558
|
+
- **object get / echo PATH [KEY]** read what an application puts on its
|
|
559
|
+
own keys, `asterism/<node>/<app>/<object>/<more>` (docs/protocol.md 2.2;
|
|
560
|
+
a game table puts its state on `<table>/state`). `PATH` is
|
|
561
|
+
`node/app/object` (`*` for a part), `KEY` the rest (`*` and `**` as whole
|
|
562
|
+
parts; `**` by default). A put is kept nowhere and a get has no answer
|
|
563
|
+
for it, so both wait for puts: `object get` prints the next one and ends
|
|
564
|
+
(`--timeout`, 5 s; an application puts when its value changes, so a quiet
|
|
565
|
+
one gives nothing), `object echo` prints them as they come: a key's
|
|
566
|
+
value whole the first time, then only what changed in it, as
|
|
567
|
+
`status echo` does (`--raw`: each value whole; `--once`: the first put;
|
|
568
|
+
`--duration`). The value is MessagePack (Asterism's own) or JSON as it
|
|
569
|
+
is, other text as a string, bytes as their count; `-o json` gives each
|
|
570
|
+
put as `{"key", "t", "bytes", "format", "value"}`. When nothing comes
|
|
571
|
+
they say so: the router does not let this node read the key (a role
|
|
572
|
+
needs `read` on it), or nothing changed.
|
|
556
573
|
- **status hz** counts the changes of the status (a put of the same status
|
|
557
574
|
again is not one) and prints, every `--period` (1 s) in which a change
|
|
558
575
|
came, the average rate and the intervals' min, max and standard
|
|
@@ -603,6 +620,7 @@ claude mcp add asterism -- asterism mcp --credentials admin.tar --allow 'games/o
|
|
|
603
620
|
| `asterism_nodes` | the nodes, their profiles, applications (with their statuses) and objects |
|
|
604
621
|
| `asterism_objects` | the objects and their methods (meta); `callable_as` names the tool for an allowed method |
|
|
605
622
|
| `asterism_status` | the applications' statuses (`node` / `app` narrow it) |
|
|
623
|
+
| `asterism_values` | the latest value of each key the applications put on (`asterism/<node>/<app>/<object>/<more>`, e.g. a game table's state), as this server received them since it started; `pattern` (`node/app/object/more`, `*` and `**`; default `**`) and `wait` (seconds, at most 30) |
|
|
606
624
|
| `<node>.<app>.<object>.<method>` | one per method an `--allow` pattern names, made from the meta: the arguments' names it tells are the inputs and its doc the description (with the object's doc after it); without names, arity n gives the inputs `arg1` .. `argN` (any JSON value), arity -1 an array `args`; `kwargs` is an object of keyword arguments. The answer is `{"value": ...}`, or an error with its kind |
|
|
607
625
|
|
|
608
626
|
- `--allow PATTERN` (repeatable): `[node/][app/]object.method`, `*` within
|
|
@@ -617,6 +635,38 @@ claude mcp add asterism -- asterism mcp --credentials admin.tar --allow 'games/o
|
|
|
617
635
|
gives the MCP client its own seat. `--app` names this application (a
|
|
618
636
|
game host that follows its players by the application `game` sees an
|
|
619
637
|
MCP client started with `--app game` come and go).
|
|
638
|
+
- **asterism_values**: from its start the server subscribes to
|
|
639
|
+
`asterism/**` (the applications' own keys; `@profile` and `@status` are
|
|
640
|
+
not under it) and keeps the last value of each key with the time it
|
|
641
|
+
came. A put comes only when a value changes and is kept nowhere else, so
|
|
642
|
+
a key that has not been put since the server started is not there:
|
|
643
|
+
`wait` waits (at most 30 s) for the first put that matches when none has
|
|
644
|
+
come yet. Each value has `key`, `value`, `t`, `age_s` (seconds since it
|
|
645
|
+
came), `bytes`, `format` and `app`: `alive`, `gone` (the application is
|
|
646
|
+
not on the network now; the value is its last one, not the present) or
|
|
647
|
+
`restarted` (it left and came back after this value). A value whose JSON
|
|
648
|
+
is longer than 8192 bytes is cut (`truncated: true`, the value the start
|
|
649
|
+
of its JSON text); the server keeps at most 1000 keys and 16 MiB (the
|
|
650
|
+
key received longest ago is forgotten first; `forgotten` counts them), a
|
|
651
|
+
single put over 256 KiB as its size only, and an answer lists at most
|
|
652
|
+
100 keys (`more`). Nothing that matches: `note` says so (not put since
|
|
653
|
+
the start, or the router does not let the server read it). A console's
|
|
654
|
+
viewer role (`read` / `watch` on `asterism/**`) is enough.
|
|
655
|
+
|
|
656
|
+
Showing an othello table to an AI assistant (the game host puts the
|
|
657
|
+
table on `games/games/othello1/state` after every move):
|
|
658
|
+
|
|
659
|
+
```
|
|
660
|
+
claude mcp add asterism -- asterism mcp --credentials viewer.tar
|
|
661
|
+
> Watch the othello game on games/games/othello1 and tell me who is winning.
|
|
662
|
+
(the assistant calls asterism_values with pattern games/games/othello1/state, wait 30)
|
|
663
|
+
{"since":"2026-10-20T02:16:50.191Z","values":[{"key":"games/games/othello1/state",
|
|
664
|
+
"t":"2026-10-20T02:16:58.583Z","age_s":0.4,"app":"alive","bytes":181,"format":"msgpack",
|
|
665
|
+
"value":{"v":15,"t":"othello1","st":"play","nm":["CPU","Alice"],"sk":[2,1],...,
|
|
666
|
+
"bd":"..W.......W.......WBWWW...WBB....W.BBB..........................",
|
|
667
|
+
"tn":0,"n":10,"last":"c1","lg":"b1b2e2f2g2b3h3b4a6","cnt":[6,8]}}]}
|
|
668
|
+
```
|
|
669
|
+
|
|
620
670
|
- An object that describes its methods at `expose` (`args:` and `doc:`,
|
|
621
671
|
"Describing the methods" below) gives the AI assistant the names and the
|
|
622
672
|
texts; without them it sees only the number of arguments.
|
|
@@ -82,7 +82,8 @@ module Asterism
|
|
|
82
82
|
case [key, i]
|
|
83
83
|
when ["node info", 0] then nodes
|
|
84
84
|
when ["app info", 0], ["status get", 0], ["status echo", 0], ["status hz", 0] then staged(cur, apps, 2)
|
|
85
|
-
when ["object list", 0], ["object info", 0], ["
|
|
85
|
+
when ["object list", 0], ["object info", 0], ["object get", 0], ["object echo", 0], ["objects", 0], ["call", 0]
|
|
86
|
+
staged(cur, objects, 3)
|
|
86
87
|
when ["ping", 0], ["echo", 0] then (cur.include?("/") ? [] : nodes) + staged(cur, objects, 3)
|
|
87
88
|
when ["object find", 0] then @source.all_methods || []
|
|
88
89
|
when ["call", 1] then methods_of(args[0])
|
data/lib/asterism/cli/mcp.rb
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
# and objects (always; reads)
|
|
10
10
|
# asterism_objects the objects and their methods (meta) (always; reads)
|
|
11
11
|
# asterism_status the applications' statuses (always; reads)
|
|
12
|
+
# asterism_values the latest value of each key the applications put
|
|
13
|
+
# on (asterism/<node>/<app>/<object>/<more>), as this
|
|
14
|
+
# server received them since it started (always;
|
|
15
|
+
# reads; lib/asterism/cli/values.rb)
|
|
12
16
|
# <node>.<app>.<object>.<method>
|
|
13
17
|
# one per method that an --allow pattern names, made
|
|
14
18
|
# from the object's meta: the arguments' names the
|
|
@@ -30,7 +34,8 @@ module Asterism
|
|
|
30
34
|
These tools look at an Asterism network: Ruby programs and boards that expose objects to each other over
|
|
31
35
|
Zenoh. Paths are <node>/<app>/<object>. asterism_nodes lists the nodes and their applications (with the
|
|
32
36
|
status each application tells about itself, a JSON object of its own), asterism_objects the objects and
|
|
33
|
-
their methods, asterism_status the statuses
|
|
37
|
+
their methods, asterism_status the statuses, asterism_values the latest values the applications put on
|
|
38
|
+
their own keys (a game table's state). The other tools call one method each, on the object their
|
|
34
39
|
name says (<node>.<app>.<object>.<method>); only the methods this server was started to allow are there.
|
|
35
40
|
A call's answer is {"value": ...}; an error says its kind (raised, not_exposed, arguments, ...).
|
|
36
41
|
TEXT
|
|
@@ -43,13 +48,17 @@ module Asterism
|
|
|
43
48
|
@call_tools = {} # tool name => [path, method, arity, meta entry]
|
|
44
49
|
@signature = nil
|
|
45
50
|
@lock = Mutex.new
|
|
51
|
+
@values = Values::Store.new(alive: ->(app) { !::Asterism.apps(app).empty? })
|
|
46
52
|
end
|
|
47
53
|
|
|
54
|
+
attr_reader :values
|
|
55
|
+
|
|
48
56
|
def run
|
|
49
57
|
@server = ::MCP::Server.new(name: "asterism", version: ::Asterism::VERSION, instructions: INSTRUCTIONS,
|
|
50
58
|
tools: read_tools)
|
|
51
59
|
@transport = ::MCP::Server::Transports::StdioTransport.new(@server)
|
|
52
60
|
@server.transport = @transport
|
|
61
|
+
remember_values
|
|
53
62
|
refresh
|
|
54
63
|
@log.puts "asterism mcp: #{@net.node_id}/#{@net.app}; #{@call_tools.size} call tools" \
|
|
55
64
|
"#{@allow.empty? ? ' (no --allow: read-only)' : ''}"
|
|
@@ -59,6 +68,16 @@ module Asterism
|
|
|
59
68
|
@refresher&.kill
|
|
60
69
|
end
|
|
61
70
|
|
|
71
|
+
# From now on, the puts on the applications' own keys (asterism/**
|
|
72
|
+
# does not match @profile and @status), and the applications that
|
|
73
|
+
# leave (their values are marked).
|
|
74
|
+
def remember_values
|
|
75
|
+
@net.session.subscribe("#{Values::PREFIX}**", depth: 1024) { |s| @values.put(s.key, s.payload) }
|
|
76
|
+
@net.session.liveliness_watch("#{Values::PREFIX}*/*", depth: 1024) do |key, alive|
|
|
77
|
+
@values.left(key.delete_prefix(Values::PREFIX)) unless alive
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
62
81
|
# ------------------------------------------------------------ the list
|
|
63
82
|
|
|
64
83
|
def refresh_loop
|
|
@@ -153,10 +172,46 @@ module Asterism
|
|
|
153
172
|
(node.nil? || node == n) && (app.nil? || app == a)
|
|
154
173
|
end
|
|
155
174
|
end
|
|
175
|
+
end,
|
|
176
|
+
::MCP::Tool.define(
|
|
177
|
+
name: "asterism_values",
|
|
178
|
+
description: VALUES_DOC,
|
|
179
|
+
input_schema: {
|
|
180
|
+
properties: {
|
|
181
|
+
pattern: { type: "string",
|
|
182
|
+
description: "keys as node/app/object/more, with * (one part) and ** (any parts); " \
|
|
183
|
+
"default ** (every key). A key always has a part after the object: " \
|
|
184
|
+
"games/games/othello1/** or games/*/*/state, not games/games/othello1" },
|
|
185
|
+
wait: { type: "number",
|
|
186
|
+
description: "seconds (at most #{Values::Store::MAX_WAIT}) to wait for the first put " \
|
|
187
|
+
"that matches when none has come yet; default 0 (answer now)" }
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
annotations: { read_only_hint: true }
|
|
191
|
+
) do |server_context:, pattern: "**", wait: 0|
|
|
192
|
+
bridge.answer { bridge.values.answer(pattern, wait: wait) }
|
|
193
|
+
rescue ArgumentError => e
|
|
194
|
+
bridge.error(e.message)
|
|
156
195
|
end
|
|
157
196
|
]
|
|
158
197
|
end
|
|
159
198
|
|
|
199
|
+
VALUES_DOC = "The latest value each application put on its own keys (asterism/<node>/<app>/<object>/<more>: " \
|
|
200
|
+
"a game table's state, a log), as this server received them: {\"since\", \"values\": " \
|
|
201
|
+
"[{\"key\", \"value\", \"t\", \"age_s\", \"app\", \"bytes\", \"format\"}]}. " \
|
|
202
|
+
"A put comes only when the value changes, and this server remembers only what it received " \
|
|
203
|
+
"since it started (since): a key that has not been put since then is not listed. Give wait " \
|
|
204
|
+
"(seconds) to wait for the first put that matches when none has come yet (a quiet table, " \
|
|
205
|
+
"just after the start). age_s: seconds since it came. app: alive; gone (the application is " \
|
|
206
|
+
"not on the network now: this is its last value, not the present); restarted (it left and " \
|
|
207
|
+
"came back after this value). value: MessagePack or JSON as it is, text as a string; a " \
|
|
208
|
+
"value whose JSON is longer than #{Values::Store::VALUE_CUT} bytes is cut (truncated: " \
|
|
209
|
+
"true, value: the start of its JSON text). It keeps at most #{Values::Store::MAX_KEYS} keys " \
|
|
210
|
+
"(those received longest ago are forgotten first) and lists at most " \
|
|
211
|
+
"#{Values::Store::MAX_ANSWER} (more: how many it left out). Nothing came: the note says so " \
|
|
212
|
+
"(not put since the start, or the router does not let this server read it). The statuses " \
|
|
213
|
+
"(@status) are asterism_status's."
|
|
214
|
+
|
|
160
215
|
def objects(pattern)
|
|
161
216
|
paths = @survey.objects(pattern)
|
|
162
217
|
metas = @survey.metas(paths)
|
data/lib/asterism/cli/nouns.rb
CHANGED
|
@@ -117,6 +117,68 @@ module Asterism
|
|
|
117
117
|
0
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
# The next value the application puts on its own keys (PATH/KEY: a put
|
|
121
|
+
# is kept nowhere, so it waits for one; --timeout, 5.0 s).
|
|
122
|
+
def cmd_object_get(rest)
|
|
123
|
+
pattern = put_pattern!(rest, "object get")
|
|
124
|
+
wait = @opts[:put_timeout] || 5.0
|
|
125
|
+
raise ArgumentError, "--timeout: more than 0" unless wait.positive?
|
|
126
|
+
code = 0
|
|
127
|
+
connect do |net, _sv|
|
|
128
|
+
q = Queue.new
|
|
129
|
+
net.session.subscribe(Values.key_expr(pattern), depth: 16) { |s| q << [s.key, s.payload, Time.now] }
|
|
130
|
+
got = q.pop(timeout: wait)
|
|
131
|
+
if got.nil?
|
|
132
|
+
@err.puts nothing_came(pattern, wait)
|
|
133
|
+
code = 1
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
e = Values.entry(*got)
|
|
137
|
+
if structured?
|
|
138
|
+
emit(e)
|
|
139
|
+
else
|
|
140
|
+
put_lines(got[2], e, nil).each { @out.puts _1 }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
code
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The values the application puts on its own keys, as they come: the
|
|
147
|
+
# text shows a key's value whole the first time (and always with
|
|
148
|
+
# --raw), then what changed; -o json / yaml each put whole.
|
|
149
|
+
def cmd_object_echo(rest)
|
|
150
|
+
pattern = put_pattern!(rest, "object echo")
|
|
151
|
+
code = 0
|
|
152
|
+
connect do |net, _sv|
|
|
153
|
+
q = Queue.new
|
|
154
|
+
net.session.subscribe(Values.key_expr(pattern), depth: 256) { |s| q << [s.key, s.payload, Time.now] }
|
|
155
|
+
seen = 0
|
|
156
|
+
last = {}
|
|
157
|
+
until_stopped(net) do |left|
|
|
158
|
+
got = q.pop(timeout: [left || 0.2, 0.2].min)
|
|
159
|
+
next unless got
|
|
160
|
+
seen += 1
|
|
161
|
+
e = Values.entry(*got)
|
|
162
|
+
if structured?
|
|
163
|
+
emit_line(e)
|
|
164
|
+
else
|
|
165
|
+
whole = @opts[:raw] || !last.key?(e["key"])
|
|
166
|
+
old = last[e["key"]]
|
|
167
|
+
last[e["key"]] = e["value"]
|
|
168
|
+
lines = whole ? put_lines(got[2], e, nil) : put_lines(got[2], e, old, again: true)
|
|
169
|
+
lines&.each { @out.puts _1 }
|
|
170
|
+
end
|
|
171
|
+
@out.flush
|
|
172
|
+
break if @opts[:once]
|
|
173
|
+
end
|
|
174
|
+
if seen.zero?
|
|
175
|
+
@err.puts nothing_came(pattern, @opts[:duration])
|
|
176
|
+
code = 1 if @opts[:once]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
code
|
|
180
|
+
end
|
|
181
|
+
|
|
120
182
|
# ------------------------------------------------------------ status
|
|
121
183
|
|
|
122
184
|
def cmd_status_get(rest)
|
|
@@ -314,6 +376,41 @@ module Asterism
|
|
|
314
376
|
path
|
|
315
377
|
end
|
|
316
378
|
|
|
379
|
+
# PATH (node/app/object, * for a part) and KEY (the rest, ** by
|
|
380
|
+
# default) as one pattern.
|
|
381
|
+
def put_pattern!(rest, what)
|
|
382
|
+
path = rest.shift
|
|
383
|
+
key = rest.shift || "**"
|
|
384
|
+
parts = path.to_s.split("/", -1)
|
|
385
|
+
unless parts.size == 3 && parts.none? { _1.empty? || (_1.include?("*") && _1 != "*") }
|
|
386
|
+
raise ArgumentError, "#{what}: PATH is node/app/object (* for a part), then KEY (asterism #{what} " \
|
|
387
|
+
"games/games/othello1 state)"
|
|
388
|
+
end
|
|
389
|
+
Values.check_pattern!("#{path}/#{key}")
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def nothing_came(pattern, seconds)
|
|
393
|
+
"asterism: nothing came on #{pattern}#{seconds ? " in #{format('%g', seconds)} s" : ''} " \
|
|
394
|
+
"(the router does not let this node read it, or it has not changed)"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# The lines of one put received at t: the time, the key and the value
|
|
398
|
+
# whole; again (the key's value before was old): a line for each thing
|
|
399
|
+
# that changed in a Hash, the value whole otherwise (nil: no change).
|
|
400
|
+
def put_lines(t, e, old, again: false)
|
|
401
|
+
head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} "
|
|
402
|
+
v = e["value"]
|
|
403
|
+
if again && old.is_a?(Hash) && v.is_a?(Hash)
|
|
404
|
+
changes = Text.status_diff(old, v)
|
|
405
|
+
return nil if changes.empty?
|
|
406
|
+
return changes.map { |c| "#{head}#{paint(e['key'], :object)} #{change_text(c)}" }
|
|
407
|
+
end
|
|
408
|
+
return nil if again && old == v
|
|
409
|
+
first = "#{head}#{paint(e['key'], :object)}"
|
|
410
|
+
return ["#{first} #{Text.inline(v)}"] unless v.is_a?(Hash)
|
|
411
|
+
[first, *Text.status_lines(v, style.width, Text.width(head))]
|
|
412
|
+
end
|
|
413
|
+
|
|
317
414
|
def no_status(path)
|
|
318
415
|
"asterism: no status from #{path} (not there, no status, or no answer in time)"
|
|
319
416
|
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# The values the applications put on their own keys
|
|
2
|
+
# (asterism/<node>/<app>/<object>/<more>, docs/protocol.md 2.2: a game
|
|
3
|
+
# table's state, a log), for `asterism object get / echo` and the MCP
|
|
4
|
+
# bridge's asterism_values. A put is not kept anywhere and a get has no
|
|
5
|
+
# answer for it (an application's queryable answers only the key shapes of
|
|
6
|
+
# 4.3), so the latest value is only what a subscriber received and kept:
|
|
7
|
+
# Values::Store keeps it, a key at a time, within limits.
|
|
8
|
+
require "json"
|
|
9
|
+
|
|
10
|
+
module Asterism
|
|
11
|
+
class CLI
|
|
12
|
+
module Values
|
|
13
|
+
PREFIX = "asterism/"
|
|
14
|
+
# Characters a part of a key cannot have (2.1); * and ** are whole
|
|
15
|
+
# parts.
|
|
16
|
+
BAD = /[*$?#]/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The pattern (node/app/object/more, * and ** as whole parts) checked;
|
|
21
|
+
# ArgumentError otherwise.
|
|
22
|
+
def check_pattern!(pattern)
|
|
23
|
+
parts = pattern.to_s.split("/", -1)
|
|
24
|
+
bad = parts.empty? || parts.any? do |p|
|
|
25
|
+
p.empty? || p.start_with?("@") || (p.match?(BAD) && !%w[* **].include?(p))
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "#{pattern.inspect}: a key's parts are names, * (one part) or ** (any parts)" if bad
|
|
28
|
+
pattern
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The Zenoh key expression of a pattern.
|
|
32
|
+
def key_expr(pattern)
|
|
33
|
+
"#{PREFIX}#{pattern}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def match?(pattern, key)
|
|
37
|
+
::Asterism.glob?(pattern, key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# [value, format] of a payload: MessagePack (Asterism's own, 3.1);
|
|
41
|
+
# else JSON text; else UTF-8 text; else "(n bytes)". The value is
|
|
42
|
+
# what JSON can hold (bytes inside as "(n bytes)").
|
|
43
|
+
def decode(payload)
|
|
44
|
+
bytes = payload.to_s.b
|
|
45
|
+
return [nil, "empty"] if bytes.empty?
|
|
46
|
+
begin
|
|
47
|
+
return [json_safe(::MessagePack.unpack(bytes)), "msgpack"]
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
52
|
+
return ["(#{bytes.bytesize} bytes)", "bytes"] unless text.valid_encoding?
|
|
53
|
+
begin
|
|
54
|
+
[json_safe(JSON.parse(text)), "json"]
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
[text, "text"]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def json_safe(v)
|
|
61
|
+
case v
|
|
62
|
+
when Hash then v.to_h { |k, x| [k.is_a?(String) ? k : k.to_s, json_safe(x)] }
|
|
63
|
+
when Array then v.map { json_safe(_1) }
|
|
64
|
+
when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
|
|
65
|
+
when Float then v.finite? ? v : v.to_s
|
|
66
|
+
when Integer, true, false, nil then v
|
|
67
|
+
when Symbol then v.to_s
|
|
68
|
+
else v.inspect
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def time(t)
|
|
73
|
+
t.getutc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One put as the command line and the MCP tool show it: {"key"
|
|
77
|
+
# (without asterism/), "t", "bytes", "format", "value"}.
|
|
78
|
+
def entry(key, payload, t = Time.now)
|
|
79
|
+
value, format = decode(payload)
|
|
80
|
+
{ "key" => key.delete_prefix(PREFIX), "t" => time(t), "bytes" => payload.to_s.bytesize,
|
|
81
|
+
"format" => format, "value" => value }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The latest put of each key, as received since it was made: at most
|
|
85
|
+
# max_keys keys and max_total payload bytes (the key received
|
|
86
|
+
# longest ago is forgotten first); a payload over max_bytes is kept
|
|
87
|
+
# as its size only. Thread safe; wait blocks until a key matches.
|
|
88
|
+
class Store
|
|
89
|
+
MAX_KEYS = 1000
|
|
90
|
+
MAX_BYTES = 256 * 1024
|
|
91
|
+
MAX_TOTAL = 16 * 1024 * 1024
|
|
92
|
+
# The JSON text of a value longer than this is cut in an answer.
|
|
93
|
+
VALUE_CUT = 8192
|
|
94
|
+
# The most keys an answer lists (the rest counted as "more").
|
|
95
|
+
MAX_ANSWER = 100
|
|
96
|
+
# The longest wait an answer may ask for (seconds).
|
|
97
|
+
MAX_WAIT = 30
|
|
98
|
+
|
|
99
|
+
attr_reader :since, :forgotten
|
|
100
|
+
|
|
101
|
+
# alive: given "<node>/<app>", true when the application is on the
|
|
102
|
+
# network now. clock: the monotonic seconds (for the tests).
|
|
103
|
+
def initialize(max_keys: MAX_KEYS, max_bytes: MAX_BYTES, max_total: MAX_TOTAL, alive: nil,
|
|
104
|
+
clock: nil, now: nil)
|
|
105
|
+
@max_keys = max_keys
|
|
106
|
+
@max_bytes = max_bytes
|
|
107
|
+
@max_total = max_total
|
|
108
|
+
@alive = alive || ->(_app) { true }
|
|
109
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
110
|
+
@now = now || -> { Time.now }
|
|
111
|
+
@since = @now.call
|
|
112
|
+
@keys = {} # key => record, the oldest received first
|
|
113
|
+
@total = 0
|
|
114
|
+
@forgotten = 0
|
|
115
|
+
@lock = Mutex.new
|
|
116
|
+
@cond = ConditionVariable.new
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A put received (key with or without asterism/).
|
|
120
|
+
def put(key, payload)
|
|
121
|
+
key = key.delete_prefix(PREFIX)
|
|
122
|
+
payload = payload.to_s
|
|
123
|
+
size = payload.bytesize
|
|
124
|
+
rec = if size > @max_bytes
|
|
125
|
+
{ "value" => "(#{size} bytes: too large to keep)", "format" => "dropped", "kept" => false }
|
|
126
|
+
else
|
|
127
|
+
v, f = Values.decode(payload)
|
|
128
|
+
{ "value" => v, "format" => f }
|
|
129
|
+
end
|
|
130
|
+
rec.merge!("key" => key, "bytes" => size, "t" => @now.call, "mono" => @clock.call, "left" => false)
|
|
131
|
+
@lock.synchronize do
|
|
132
|
+
old = @keys.delete(key)
|
|
133
|
+
@total -= old["kept"] == false ? 0 : old["bytes"] if old
|
|
134
|
+
@keys[key] = rec
|
|
135
|
+
@total += size unless rec["kept"] == false
|
|
136
|
+
while @keys.size > @max_keys || (@total > @max_total && @keys.size > 1)
|
|
137
|
+
_, gone = @keys.shift
|
|
138
|
+
@total -= gone["bytes"] unless gone["kept"] == false
|
|
139
|
+
@forgotten += 1
|
|
140
|
+
end
|
|
141
|
+
@cond.broadcast
|
|
142
|
+
end
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The application "<node>/<app>" left the network: its values are
|
|
147
|
+
# from before (kept, marked).
|
|
148
|
+
def left(app)
|
|
149
|
+
prefix = "#{app}/"
|
|
150
|
+
@lock.synchronize { @keys.each_value { |r| r["left"] = true if r["key"].start_with?(prefix) } }
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def size
|
|
155
|
+
@lock.synchronize { @keys.size }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def keys
|
|
159
|
+
@lock.synchronize { @keys.keys }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The records whose keys match (sorted by key); with wait (seconds),
|
|
163
|
+
# until one matches or the time is up.
|
|
164
|
+
def match(pattern, wait: 0)
|
|
165
|
+
deadline = @clock.call + wait.to_f
|
|
166
|
+
@lock.synchronize do
|
|
167
|
+
loop do
|
|
168
|
+
got = @keys.values.select { Values.match?(pattern, _1["key"]) }
|
|
169
|
+
return got.sort_by { _1["key"] } unless got.empty?
|
|
170
|
+
left = deadline - @clock.call
|
|
171
|
+
return [] if left <= 0
|
|
172
|
+
@cond.wait(@lock, left)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# What asterism_values answers: {"since", "values" => [{"key", "t",
|
|
178
|
+
# "age_s", "app", "bytes", "format", "value"(, "truncated")}]}, with
|
|
179
|
+
# "more" when more keys matched than an answer lists, "forgotten"
|
|
180
|
+
# when the limits made it forget keys, and "note" when nothing came.
|
|
181
|
+
def answer(pattern = "**", wait: 0)
|
|
182
|
+
Values.check_pattern!(pattern)
|
|
183
|
+
wait = wait.to_f.clamp(0, MAX_WAIT)
|
|
184
|
+
recs = match(pattern, wait: wait)
|
|
185
|
+
now = @clock.call
|
|
186
|
+
alive = {}
|
|
187
|
+
out = { "since" => Values.time(@since) }
|
|
188
|
+
out["values"] = recs.first(MAX_ANSWER).map do |r|
|
|
189
|
+
app = r["key"].split("/")[0, 2].join("/")
|
|
190
|
+
here = alive.key?(app) ? alive[app] : (alive[app] = @alive.call(app) ? true : false)
|
|
191
|
+
h = { "key" => r["key"], "t" => Values.time(r["t"]), "age_s" => (now - r["mono"]).round(1),
|
|
192
|
+
"app" => here ? (r["left"] ? "restarted" : "alive") : "gone", "bytes" => r["bytes"],
|
|
193
|
+
"format" => r["format"] }
|
|
194
|
+
text = JSON.generate(r["value"])
|
|
195
|
+
if text.bytesize > VALUE_CUT
|
|
196
|
+
h["value"] = "#{text.byteslice(0, VALUE_CUT).scrub('')}…"
|
|
197
|
+
h["truncated"] = true
|
|
198
|
+
else
|
|
199
|
+
h["value"] = r["value"]
|
|
200
|
+
end
|
|
201
|
+
h["truncated"] = true if r["kept"] == false
|
|
202
|
+
h
|
|
203
|
+
end
|
|
204
|
+
out["more"] = recs.size - MAX_ANSWER if recs.size > MAX_ANSWER
|
|
205
|
+
out["forgotten"] = @forgotten if @forgotten.positive?
|
|
206
|
+
if recs.empty?
|
|
207
|
+
out["note"] = "nothing came for #{pattern}#{wait.positive? ? " in #{format('%g', wait)} s" : ''}: " \
|
|
208
|
+
"it has not been put since this server started (puts come only when a value " \
|
|
209
|
+
"changes), or the router does not let this server read it"
|
|
210
|
+
end
|
|
211
|
+
out
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
data/lib/asterism/cli.rb
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
# asterism object list 'games/games/*' / object info games/games/othello1
|
|
9
9
|
# asterism object find join # the objects that have join
|
|
10
10
|
# asterism status get games/games # echo: as it changes, hz: how often
|
|
11
|
+
# asterism object get games/games/othello1 state # the next put there
|
|
11
12
|
# asterism call games/games/othello1 view '"<token>"'
|
|
12
13
|
# asterism ping / watch / tree / mcp # as before
|
|
13
14
|
# asterism completion bash # the shell's Tab completion
|
|
@@ -30,6 +31,7 @@ require_relative "cli/allow"
|
|
|
30
31
|
require_relative "cli/text"
|
|
31
32
|
require_relative "cli/hz"
|
|
32
33
|
require_relative "cli/complete"
|
|
34
|
+
require_relative "cli/values"
|
|
33
35
|
|
|
34
36
|
module Asterism
|
|
35
37
|
class CLI
|
|
@@ -46,7 +48,11 @@ module Asterism
|
|
|
46
48
|
"object" => {
|
|
47
49
|
"list" => ["[PATTERN]", "the objects, node/app/object a line (PATTERN: * and ** for whole parts)"],
|
|
48
50
|
"info" => ["PATH", "one object (node/app/object): its description and its methods"],
|
|
49
|
-
"find" => ["METHOD", "the objects that have the method (* as a wildcard)"]
|
|
51
|
+
"find" => ["METHOD", "the objects that have the method (* as a wildcard)"],
|
|
52
|
+
"get" => ["PATH [KEY]", "the next value the application puts on PATH/KEY (its own keys; KEY: ** " \
|
|
53
|
+
"by default; --timeout 5.0)"],
|
|
54
|
+
"echo" => ["PATH [KEY]", "the values it puts there as they come (the text: what changed; --once: " \
|
|
55
|
+
"the first; --raw: each whole)"]
|
|
50
56
|
},
|
|
51
57
|
"status" => {
|
|
52
58
|
"get" => ["NODE/APP", "an application's status, now"],
|
|
@@ -199,7 +205,11 @@ module Asterism
|
|
|
199
205
|
end
|
|
200
206
|
o.on("--json", "JSON instead of text (-o json)") { @opts[:format] = :json }
|
|
201
207
|
end
|
|
202
|
-
|
|
208
|
+
if key == "object get"
|
|
209
|
+
o.on("--timeout SECONDS", Float, "how long to wait for a put (5.0)") { @opts[:put_timeout] = _1 }
|
|
210
|
+
else
|
|
211
|
+
o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
|
|
212
|
+
end
|
|
203
213
|
o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
|
|
204
214
|
end
|
|
205
215
|
case key
|
|
@@ -216,6 +226,10 @@ module Asterism
|
|
|
216
226
|
when "mcp"
|
|
217
227
|
o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
|
|
218
228
|
"(repeatable)") { @opts[:allow] << _1 }
|
|
229
|
+
when "object echo"
|
|
230
|
+
o.on("--once", "the first put, then stop") { @opts[:once] = true }
|
|
231
|
+
o.on("--raw", "each value whole (the text; else what changed)") { @opts[:raw] = true }
|
|
232
|
+
o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
|
|
219
233
|
when "status echo"
|
|
220
234
|
o.on("--once", "the status now, once, then stop") { @opts[:once] = true }
|
|
221
235
|
o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
|
data/lib/asterism/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asterism
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Katsuhiko Kageyama
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: asterism-zenoh
|
|
@@ -47,7 +46,6 @@ dependencies:
|
|
|
47
46
|
description: Proxies for objects exposed by other Ruby processes and boards, a ROS
|
|
48
47
|
2 node speaking rmw_zenoh's wire format, CDR and generated message types. Pure Ruby;
|
|
49
48
|
the same files are the mrbgem for mruby / PicoRuby.
|
|
50
|
-
email:
|
|
51
49
|
executables:
|
|
52
50
|
- asterism
|
|
53
51
|
extensions: []
|
|
@@ -156,6 +154,7 @@ files:
|
|
|
156
154
|
- lib/asterism/cli/nouns.rb
|
|
157
155
|
- lib/asterism/cli/survey.rb
|
|
158
156
|
- lib/asterism/cli/text.rb
|
|
157
|
+
- lib/asterism/cli/values.rb
|
|
159
158
|
- lib/asterism/cruby.rb
|
|
160
159
|
- lib/asterism/cruby/credentials.rb
|
|
161
160
|
- lib/asterism/cruby/objects.rb
|
|
@@ -182,7 +181,6 @@ metadata:
|
|
|
182
181
|
source_code_uri: https://github.com/ruby-asterism/asterism
|
|
183
182
|
bug_tracker_uri: https://github.com/ruby-asterism/asterism/issues
|
|
184
183
|
rubygems_mfa_required: 'true'
|
|
185
|
-
post_install_message:
|
|
186
184
|
rdoc_options: []
|
|
187
185
|
require_paths:
|
|
188
186
|
- lib
|
|
@@ -190,15 +188,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
190
188
|
requirements:
|
|
191
189
|
- - ">="
|
|
192
190
|
- !ruby/object:Gem::Version
|
|
193
|
-
version: '3.
|
|
191
|
+
version: '3.4'
|
|
194
192
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
195
193
|
requirements:
|
|
196
194
|
- - ">="
|
|
197
195
|
- !ruby/object:Gem::Version
|
|
198
196
|
version: '0'
|
|
199
197
|
requirements: []
|
|
200
|
-
rubygems_version: 3.
|
|
201
|
-
signing_key:
|
|
198
|
+
rubygems_version: 3.6.9
|
|
202
199
|
specification_version: 4
|
|
203
200
|
summary: 'Asterism: Ruby objects on other machines, and ROS 2 (rmw_zenoh) nodes, over
|
|
204
201
|
Zenoh'
|