asterism 0.8.0 → 0.10.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 +71 -0
- data/README.md +193 -21
- data/lib/asterism/cli/complete.rb +283 -0
- data/lib/asterism/cli/hz.rb +77 -0
- data/lib/asterism/cli/mcp.rb +56 -1
- data/lib/asterism/cli/nouns.rb +493 -0
- data/lib/asterism/cli/survey.rb +6 -4
- data/lib/asterism/cli/text.rb +256 -0
- data/lib/asterism/cli/values.rb +216 -0
- data/lib/asterism/cli.rb +389 -98
- data/lib/asterism/version.rb +1 -1
- metadata +7 -2
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# `asterism status hz`: how often an application's status changes, as
|
|
2
|
+
# `ros2 topic hz` tells how often a topic is published. Each change is a
|
|
3
|
+
# time (a monotonic clock, given or Process.clock_gettime); the intervals
|
|
4
|
+
# between the last ones (a window of them) give the average rate and the
|
|
5
|
+
# intervals' min, max and standard deviation. A put that brings the same
|
|
6
|
+
# status again (as after a reconnection) is not a change.
|
|
7
|
+
module Asterism
|
|
8
|
+
class CLI
|
|
9
|
+
class Hz
|
|
10
|
+
attr_reader :count
|
|
11
|
+
|
|
12
|
+
# window: how many of the last intervals count. clock: a callable
|
|
13
|
+
# that returns the time in seconds (monotonic).
|
|
14
|
+
def initialize(window: 1000, clock: nil)
|
|
15
|
+
@window = window
|
|
16
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
17
|
+
@times = []
|
|
18
|
+
@last = nil
|
|
19
|
+
@seeded = false
|
|
20
|
+
@count = 0
|
|
21
|
+
@reported = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The status there at the start: what the first put is compared with
|
|
25
|
+
# (not a change itself).
|
|
26
|
+
def seed(status)
|
|
27
|
+
@last = status
|
|
28
|
+
@seeded = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A put of the status: a change (true) when it differs from the last
|
|
32
|
+
# one seen (the first put without a seed is one).
|
|
33
|
+
def put(status)
|
|
34
|
+
return false if @seeded && status == @last
|
|
35
|
+
@last = status
|
|
36
|
+
@seeded = true
|
|
37
|
+
add(@clock.call)
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A change at time t (seconds).
|
|
42
|
+
def add(t)
|
|
43
|
+
@count += 1
|
|
44
|
+
@times << t
|
|
45
|
+
@times.shift while @times.size > @window + 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# {"rate_hz", "min_s", "max_s", "std_s", "window", "count"} over the
|
|
49
|
+
# intervals in the window, or nil before two changes.
|
|
50
|
+
def stats
|
|
51
|
+
return nil if @times.size < 2
|
|
52
|
+
ints = @times.each_cons(2).map { |a, b| b - a }
|
|
53
|
+
avg = ints.sum / ints.size
|
|
54
|
+
var = ints.sum { (_1 - avg)**2 } / ints.size
|
|
55
|
+
{ "rate_hz" => avg.positive? ? 1.0 / avg : nil, "min_s" => ints.min, "max_s" => ints.max,
|
|
56
|
+
"std_s" => Math.sqrt(var), "window" => ints.size, "count" => @count }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# stats when a change came since the last report (else nil): what a
|
|
60
|
+
# period prints.
|
|
61
|
+
def report
|
|
62
|
+
return nil if @count == @reported
|
|
63
|
+
s = stats
|
|
64
|
+
@reported = @count if s
|
|
65
|
+
s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The lines of a report, as ros2 topic hz prints them.
|
|
69
|
+
def self.text(s)
|
|
70
|
+
rate = s["rate_hz"] ? format("%.3f", s["rate_hz"]) : "inf"
|
|
71
|
+
["average rate: #{rate} Hz",
|
|
72
|
+
format(" min: %.3f s max: %.3f s std dev: %.3f s window: %d", s["min_s"], s["max_s"], s["std_s"],
|
|
73
|
+
s["window"])]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
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)
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# The noun-verb commands of the command line (node, app, object, status:
|
|
2
|
+
# lib/asterism/cli.rb's NOUNS), `completion` and the hidden `__complete`
|
|
3
|
+
# (lib/asterism/cli/complete.rb).
|
|
4
|
+
module Asterism
|
|
5
|
+
class CLI
|
|
6
|
+
# ------------------------------------------------------------ node
|
|
7
|
+
|
|
8
|
+
def cmd_node_list(_rest)
|
|
9
|
+
connect do |_net, sv|
|
|
10
|
+
names = sv.nodes
|
|
11
|
+
structured? ? emit(names) : names.each { @out.puts paint(_1, :node) }
|
|
12
|
+
end
|
|
13
|
+
0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cmd_node_info(rest)
|
|
17
|
+
node = rest.shift
|
|
18
|
+
raise ArgumentError, "node info: which node? (asterism node info NODE)" unless node && !node.empty?
|
|
19
|
+
raise ArgumentError, "node info: NODE is a node's name (no /)" if node.include?("/")
|
|
20
|
+
code = 0
|
|
21
|
+
connect do |_net, sv|
|
|
22
|
+
entry = sv.tree(node: node).first
|
|
23
|
+
if entry.nil?
|
|
24
|
+
@err.puts "asterism: no node #{node} is visible"
|
|
25
|
+
code = 1
|
|
26
|
+
elsif structured?
|
|
27
|
+
emit(entry)
|
|
28
|
+
else
|
|
29
|
+
@out.puts paint(entry["node"], :node)
|
|
30
|
+
if entry["profile"]
|
|
31
|
+
pr = entry["profile"].except("icon")
|
|
32
|
+
Text.wrap(pr.map { |k, v| Text.pair(k, v) }, style.width, first: " #{paint('profile:', :note)} ",
|
|
33
|
+
rest: " ", break_long: false)
|
|
34
|
+
.each { @out.puts _1 }
|
|
35
|
+
end
|
|
36
|
+
entry["apps"].each { |a| app_block(a["app"], a, 2) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
code
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------ app
|
|
43
|
+
|
|
44
|
+
def cmd_app_list(_rest)
|
|
45
|
+
connect do |_net, sv|
|
|
46
|
+
names = sv.apps
|
|
47
|
+
structured? ? emit(names) : names.each { @out.puts paint_path(_1) }
|
|
48
|
+
end
|
|
49
|
+
0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def cmd_app_info(rest)
|
|
53
|
+
node, app = app_path!(rest, "app info").split("/")
|
|
54
|
+
code = 0
|
|
55
|
+
connect do |_net, sv|
|
|
56
|
+
a = sv.tree(node: node, app: app).first&.dig("apps")&.find { _1["app"] == app }
|
|
57
|
+
if a.nil?
|
|
58
|
+
@err.puts "asterism: no application #{node}/#{app} is visible"
|
|
59
|
+
code = 1
|
|
60
|
+
elsif structured?
|
|
61
|
+
emit({ "node" => node }.merge(a))
|
|
62
|
+
else
|
|
63
|
+
app_block("#{node}/#{app}", a, 0)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
code
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------ object
|
|
70
|
+
|
|
71
|
+
def cmd_object_list(rest)
|
|
72
|
+
pattern = rest.shift || "**"
|
|
73
|
+
connect do |_net, sv|
|
|
74
|
+
paths = sv.objects(pattern)
|
|
75
|
+
structured? ? emit(paths) : paths.each { @out.puts paint_path(_1) }
|
|
76
|
+
end
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cmd_object_info(rest)
|
|
81
|
+
path = rest.shift
|
|
82
|
+
unless path && path.split("/", -1).size == 3 && path.split("/").none? { _1.empty? || _1.include?("*") }
|
|
83
|
+
raise ArgumentError, "object info: PATH is node/app/object (a pattern: asterism object list PATTERN)"
|
|
84
|
+
end
|
|
85
|
+
code = 0
|
|
86
|
+
connect do |_net, sv|
|
|
87
|
+
if sv.objects(path).empty?
|
|
88
|
+
@err.puts "asterism: no object #{path} is visible"
|
|
89
|
+
code = 1
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
methods = sv.metas([path])[path]
|
|
93
|
+
if structured?
|
|
94
|
+
h = { "path" => path, "methods" => methods }
|
|
95
|
+
h["doc"] = sv.object_doc(path) if sv.object_doc(path)
|
|
96
|
+
emit(h)
|
|
97
|
+
else
|
|
98
|
+
object_entry(path, methods, sv.object_doc(path))
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
code
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cmd_object_find(rest)
|
|
105
|
+
want = rest.shift
|
|
106
|
+
raise ArgumentError, "object find: which method? (asterism object find METHOD; * as a wildcard)" unless want
|
|
107
|
+
found = []
|
|
108
|
+
connect do |_net, sv|
|
|
109
|
+
paths = sv.objects
|
|
110
|
+
metas = sv.metas(paths)
|
|
111
|
+
paths.each do |p|
|
|
112
|
+
ms = (metas[p] || []).select { File.fnmatch?(want, _1["name"]) }
|
|
113
|
+
found << { "path" => p, "methods" => ms } unless ms.empty?
|
|
114
|
+
end
|
|
115
|
+
structured? ? emit(found) : found.each { @out.puts paint_path(_1["path"]) }
|
|
116
|
+
end
|
|
117
|
+
0
|
|
118
|
+
end
|
|
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
|
+
|
|
182
|
+
# ------------------------------------------------------------ status
|
|
183
|
+
|
|
184
|
+
def cmd_status_get(rest)
|
|
185
|
+
path = app_path!(rest, "status get")
|
|
186
|
+
code = 0
|
|
187
|
+
connect do |_net, sv|
|
|
188
|
+
st = sv.statuses([path])[path]
|
|
189
|
+
if st.nil?
|
|
190
|
+
@err.puts no_status(path)
|
|
191
|
+
code = 1
|
|
192
|
+
elsif structured?
|
|
193
|
+
emit(json_safe(st))
|
|
194
|
+
else
|
|
195
|
+
Text.status_lines(json_safe(st), style.width).each { @out.puts _1 }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
code
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The status, then each change as it comes (the text: what changed, as
|
|
202
|
+
# watch tells it); --once: the status now, once.
|
|
203
|
+
def cmd_status_echo(rest)
|
|
204
|
+
path = app_path!(rest, "status echo")
|
|
205
|
+
code = 0
|
|
206
|
+
connect do |net, sv|
|
|
207
|
+
q = Queue.new
|
|
208
|
+
subscribe_status(net, path) { |st| q << [Time.now, st] } unless @opts[:once]
|
|
209
|
+
st = sv.statuses([path])[path]
|
|
210
|
+
if st
|
|
211
|
+
echo_event(Time.now, path, st, true)
|
|
212
|
+
elsif @opts[:once]
|
|
213
|
+
@err.puts no_status(path)
|
|
214
|
+
code = 1
|
|
215
|
+
end
|
|
216
|
+
next if @opts[:once]
|
|
217
|
+
|
|
218
|
+
until_stopped(net) do |left|
|
|
219
|
+
got = q.pop(timeout: [left || 0.2, 0.2].min)
|
|
220
|
+
echo_event(got[0], path, got[1], false) if got
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
code
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# How often the status changes: every --period, when a change came, the
|
|
227
|
+
# average rate and the intervals (Hz).
|
|
228
|
+
def cmd_status_hz(rest)
|
|
229
|
+
path = app_path!(rest, "status hz")
|
|
230
|
+
raise ArgumentError, "--window: 1 or more" unless @opts[:window].positive?
|
|
231
|
+
raise ArgumentError, "--period: more than 0" unless @opts[:period].positive?
|
|
232
|
+
connect do |net, sv|
|
|
233
|
+
hz = Hz.new(window: @opts[:window])
|
|
234
|
+
lock = Mutex.new
|
|
235
|
+
subscribe_status(net, path) { |st| lock.synchronize { hz.put(st) } }
|
|
236
|
+
st = sv.statuses([path])[path]
|
|
237
|
+
lock.synchronize { hz.seed(st) } if st
|
|
238
|
+
@err.puts "asterism: #{path}: counting the status's changes (Ctrl-C to stop)" unless structured?
|
|
239
|
+
nxt = now + @opts[:period]
|
|
240
|
+
until_stopped(net) do |_left|
|
|
241
|
+
sleep 0.05
|
|
242
|
+
next if now < nxt
|
|
243
|
+
nxt += @opts[:period]
|
|
244
|
+
s = lock.synchronize { hz.report }
|
|
245
|
+
next unless s
|
|
246
|
+
if structured?
|
|
247
|
+
emit_line({ "t" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path }.merge(s))
|
|
248
|
+
else
|
|
249
|
+
Hz.text(s).each { @out.puts _1 }
|
|
250
|
+
end
|
|
251
|
+
@out.flush
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
0
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------ completion
|
|
258
|
+
|
|
259
|
+
def cmd_completion(rest)
|
|
260
|
+
case rest.shift
|
|
261
|
+
when "bash" then @out.write(Complete::BASH)
|
|
262
|
+
when "zsh" then @out.write(Complete::ZSH)
|
|
263
|
+
else raise ArgumentError, "completion: bash or zsh (source <(asterism completion bash))"
|
|
264
|
+
end
|
|
265
|
+
0
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The candidates for the word being completed, one a line. The words
|
|
269
|
+
# come as arguments (the last one the word being completed), after
|
|
270
|
+
# "--"; or (--bash LINE) as bash's line up to the cursor.
|
|
271
|
+
def cmd_complete(argv)
|
|
272
|
+
bash = argv.first == "--bash"
|
|
273
|
+
words = if bash
|
|
274
|
+
Complete.split_line(argv[1].to_s)
|
|
275
|
+
else
|
|
276
|
+
argv.first == "--" ? argv.drop(1) : argv
|
|
277
|
+
end
|
|
278
|
+
words = [""] if words.empty?
|
|
279
|
+
@complete_words = words
|
|
280
|
+
cands = Complete.new(self, CompleteSource.new(self, words)).candidates(words)
|
|
281
|
+
cands = Complete.bash_trim(cands, words.last) if bash && cands != [Complete::FILES]
|
|
282
|
+
cands.each { @out.puts _1 }
|
|
283
|
+
@out.flush
|
|
284
|
+
# A connection still trying past the budget is left behind: the
|
|
285
|
+
# process ends without waiting for it.
|
|
286
|
+
Process.exit!(0) if @complete_abandoned
|
|
287
|
+
0
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# What __complete asks the network for, through the cache: the names
|
|
291
|
+
# (the tokens) and an object's methods (its meta).
|
|
292
|
+
class CompleteSource
|
|
293
|
+
def initialize(cli, words)
|
|
294
|
+
@cli = cli
|
|
295
|
+
@words = words
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def names
|
|
299
|
+
cached("names") do
|
|
300
|
+
@cli.complete_fetch { |sv| { "apps" => sv.apps, "objects" => sv.objects } }
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def methods(path)
|
|
305
|
+
cached("methods #{path}") do
|
|
306
|
+
@cli.complete_fetch { |sv| sv.metas([path])[path]&.map { _1["name"] } }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def all_methods
|
|
311
|
+
cached("methods") do
|
|
312
|
+
@cli.complete_fetch { |sv| sv.metas(sv.objects).values.flatten.map { _1["name"] }.uniq.sort }
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
private
|
|
317
|
+
|
|
318
|
+
def cached(entry, &blk)
|
|
319
|
+
net = @cli.complete_network(@words)
|
|
320
|
+
return nil if net.none?
|
|
321
|
+
dir = Complete::Cache.dir(@cli.env)
|
|
322
|
+
return blk.call unless dir
|
|
323
|
+
Complete::Cache.new(dir: dir, network: net).fetch(entry, &blk)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
attr_reader :env
|
|
328
|
+
|
|
329
|
+
# Where the network is for __complete: the options among the words,
|
|
330
|
+
# else the environment (network). [router, credentials (its full path),
|
|
331
|
+
# peer]
|
|
332
|
+
def complete_network(words)
|
|
333
|
+
words.each_cons(2) do |a, b|
|
|
334
|
+
case a
|
|
335
|
+
when "--router" then @opts[:router] = b
|
|
336
|
+
when "--credentials" then @opts[:credentials] = b
|
|
337
|
+
when "--peer" then @opts[:peer] = b
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
r, c, pr = network
|
|
341
|
+
[r, c && File.expand_path(c), pr]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Connects for at most Complete::BUDGET seconds and yields the Survey;
|
|
345
|
+
# the block's value, or nil when the network did not answer in time.
|
|
346
|
+
def complete_fetch
|
|
347
|
+
@opts[:settle] = 0.6
|
|
348
|
+
@opts[:timeout] = 0.6
|
|
349
|
+
out = Queue.new
|
|
350
|
+
th = Thread.new do
|
|
351
|
+
err = @err
|
|
352
|
+
@err = File.open(File::NULL, "w")
|
|
353
|
+
begin
|
|
354
|
+
connect { |_net, sv| out << yield(sv) }
|
|
355
|
+
rescue StandardError, ScriptError
|
|
356
|
+
out << nil
|
|
357
|
+
ensure
|
|
358
|
+
@err = err
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
v = out.pop(timeout: Complete::BUDGET)
|
|
362
|
+
if v.nil? && th.alive?
|
|
363
|
+
@complete_abandoned = true
|
|
364
|
+
return nil
|
|
365
|
+
end
|
|
366
|
+
th.join(0.5) || (@complete_abandoned = true)
|
|
367
|
+
v
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# ------------------------------------------------------------ helpers
|
|
371
|
+
|
|
372
|
+
def app_path!(rest, what)
|
|
373
|
+
path = rest.shift
|
|
374
|
+
ok = path && path.split("/", -1).size == 2 && path.split("/").none? { _1.empty? || _1.include?("*") }
|
|
375
|
+
raise ArgumentError, "#{what}: which application? (asterism #{what} NODE/APP)" unless ok
|
|
376
|
+
path
|
|
377
|
+
end
|
|
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
|
+
|
|
414
|
+
def no_status(path)
|
|
415
|
+
"asterism: no status from #{path} (not there, no status, or no answer in time)"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# node/app[/object] painted part by part.
|
|
419
|
+
def paint_path(path)
|
|
420
|
+
parts = path.split("/")
|
|
421
|
+
roles = %i[node app object]
|
|
422
|
+
parts.each_with_index.map { |p, i| paint(p, roles[i] || :object) }.join(paint("/", :note))
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# An application (a tree's entry) under its name: its status and its
|
|
426
|
+
# objects (how many methods, and the object's description).
|
|
427
|
+
def app_block(name, a, indent)
|
|
428
|
+
pad = " " * indent
|
|
429
|
+
@out.puts "#{pad}#{paint(name, :app)}"
|
|
430
|
+
status_block(a, indent + 2)
|
|
431
|
+
return if a["objects"].empty?
|
|
432
|
+
@out.puts "#{pad} #{paint('objects:', :note)}"
|
|
433
|
+
a["objects"].each do |o|
|
|
434
|
+
ms = o["methods"]
|
|
435
|
+
@out.puts "#{pad} #{paint(o['name'], :object)} #{paint(ms ? count(ms.size, 'method') : '(no meta)', :note)}"
|
|
436
|
+
Text.wrap_text(o["doc"], style.width, first: "#{pad} ").each { @out.puts _1 } if o["doc"]
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# The status's puts of one application, cleaned (Status.parse).
|
|
441
|
+
def subscribe_status(net, path)
|
|
442
|
+
net.session.subscribe("asterism/#{path}/@status") do |sample|
|
|
443
|
+
got = ::Asterism::Status.parse(sample.key, sample.payload)
|
|
444
|
+
yield got[2] if got
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Yields until Ctrl-C, --duration or a lost connection (the seconds
|
|
449
|
+
# left of --duration, or nil).
|
|
450
|
+
def until_stopped(net)
|
|
451
|
+
deadline = @opts[:duration] && (now + @opts[:duration])
|
|
452
|
+
loop do
|
|
453
|
+
left = deadline && (deadline - now)
|
|
454
|
+
break if left && left <= 0
|
|
455
|
+
break unless net.connected?
|
|
456
|
+
yield left
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def now
|
|
461
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# One record of status echo: JSON / YAML, or the text (the status whole
|
|
465
|
+
# the first time, then a line for each change).
|
|
466
|
+
def echo_event(t, path, status, first)
|
|
467
|
+
status = json_safe(status)
|
|
468
|
+
if structured?
|
|
469
|
+
h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path, "status" => status }
|
|
470
|
+
h["now"] = true if first
|
|
471
|
+
emit_line(h)
|
|
472
|
+
else
|
|
473
|
+
text = echo_text(t, path, status, first)
|
|
474
|
+
@out.puts text if text
|
|
475
|
+
end
|
|
476
|
+
@out.flush
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# The text of one record of status echo (nil: no change).
|
|
480
|
+
def echo_text(t, path, status, first)
|
|
481
|
+
head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} "
|
|
482
|
+
old = @echo_last
|
|
483
|
+
@echo_last = status
|
|
484
|
+
if first || !old.is_a?(Hash) || !status.is_a?(Hash)
|
|
485
|
+
lines = Text.status_lines(status, style.width, Text.width(head))
|
|
486
|
+
return "#{head}#{paint(path, :app)}#{paint(' (now)', :note) if first}\n#{lines.join("\n")}"
|
|
487
|
+
end
|
|
488
|
+
changes = Text.status_diff(old, status)
|
|
489
|
+
return nil if changes.empty?
|
|
490
|
+
changes.map { |c| "#{head}#{change_text(c)}" }.join("\n")
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
end
|
data/lib/asterism/cli/survey.rb
CHANGED
|
@@ -115,10 +115,12 @@ module Asterism
|
|
|
115
115
|
|
|
116
116
|
# The tree: [{"node", "profile", "apps" => [{"app", "status",
|
|
117
117
|
# "objects" => [{"name", "methods"}]}]}]. meta: false leaves the
|
|
118
|
-
# methods out (no meta gets)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
# methods out (no meta gets); node: (and app:) narrow it to one node
|
|
119
|
+
# (one application).
|
|
120
|
+
def tree(meta: true, node: nil, app: nil)
|
|
121
|
+
scope = node && "#{node}/#{app || '*'}"
|
|
122
|
+
objs = scope ? objects("#{scope}/*") : objects
|
|
123
|
+
app_list = scope ? apps(scope) : apps
|
|
122
124
|
threads = []
|
|
123
125
|
node_list = (app_list.map { _1.split("/")[0] } + objs.map { _1.split("/")[0] }).uniq
|
|
124
126
|
threads << Thread.new { profiles(node_list) }
|