asterism 0.7.1 → 0.9.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 +100 -0
- data/README.md +288 -5
- data/exe/asterism +8 -0
- data/lib/asterism/cli/allow.rb +53 -0
- data/lib/asterism/cli/complete.rb +282 -0
- data/lib/asterism/cli/hz.rb +77 -0
- data/lib/asterism/cli/literal.rb +132 -0
- data/lib/asterism/cli/mcp.rb +272 -0
- data/lib/asterism/cli/nouns.rb +396 -0
- data/lib/asterism/cli/survey.rb +206 -0
- data/lib/asterism/cli/text.rb +256 -0
- data/lib/asterism/cli.rb +701 -0
- data/lib/asterism/cruby/credentials.rb +61 -116
- data/lib/asterism/cruby/objects.rb +60 -7
- data/lib/asterism/version.rb +1 -1
- data/lib/asterism.rb +1 -1
- data/mrblib/asterism.rb +206 -23
- data/mrblib/credentials.rb +197 -0
- data/mrblib/status.rb +198 -0
- metadata +17 -4
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# `asterism mcp`: Asterism as tools of an MCP client (an AI assistant),
|
|
2
|
+
# over stdio, with the mcp gem (an optional dependency: `gem install mcp`;
|
|
3
|
+
# the rest of the command line does not need it).
|
|
4
|
+
#
|
|
5
|
+
# asterism mcp --router tcp/192.0.2.2:7447 --allow 'games/othello1.*'
|
|
6
|
+
#
|
|
7
|
+
# Tools:
|
|
8
|
+
# asterism_nodes the nodes, their applications (with their statuses)
|
|
9
|
+
# and objects (always; reads)
|
|
10
|
+
# asterism_objects the objects and their methods (meta) (always; reads)
|
|
11
|
+
# asterism_status the applications' statuses (always; reads)
|
|
12
|
+
# <node>.<app>.<object>.<method>
|
|
13
|
+
# one per method that an --allow pattern names, made
|
|
14
|
+
# from the object's meta: the arguments' names the
|
|
15
|
+
# meta tells (docs/protocol.md 5) are the inputs and
|
|
16
|
+
# its doc the description; without names, arity n
|
|
17
|
+
# gives arg1 .. argN (any JSON value), arity -1 an
|
|
18
|
+
# array "args"; "kwargs" (an object) is the keyword
|
|
19
|
+
# arguments. Nothing is callable without --allow.
|
|
20
|
+
#
|
|
21
|
+
# The call tools follow the network: when objects appear or go, the list is
|
|
22
|
+
# made again and the client is told (notifications/tools/list_changed).
|
|
23
|
+
require "mcp"
|
|
24
|
+
|
|
25
|
+
module Asterism
|
|
26
|
+
class CLI
|
|
27
|
+
class MCPBridge
|
|
28
|
+
REFRESH_S = 2.0
|
|
29
|
+
INSTRUCTIONS = <<~TEXT.freeze
|
|
30
|
+
These tools look at an Asterism network: Ruby programs and boards that expose objects to each other over
|
|
31
|
+
Zenoh. Paths are <node>/<app>/<object>. asterism_nodes lists the nodes and their applications (with the
|
|
32
|
+
status each application tells about itself, a JSON object of its own), asterism_objects the objects and
|
|
33
|
+
their methods, asterism_status the statuses. The other tools call one method each, on the object their
|
|
34
|
+
name says (<node>.<app>.<object>.<method>); only the methods this server was started to allow are there.
|
|
35
|
+
A call's answer is {"value": ...}; an error says its kind (raised, not_exposed, arguments, ...).
|
|
36
|
+
TEXT
|
|
37
|
+
|
|
38
|
+
def initialize(net, survey, allow:, log: $stderr)
|
|
39
|
+
@net = net
|
|
40
|
+
@survey = survey
|
|
41
|
+
@allow = Allow.new(allow)
|
|
42
|
+
@log = log
|
|
43
|
+
@call_tools = {} # tool name => [path, method, arity, meta entry]
|
|
44
|
+
@signature = nil
|
|
45
|
+
@lock = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run
|
|
49
|
+
@server = ::MCP::Server.new(name: "asterism", version: ::Asterism::VERSION, instructions: INSTRUCTIONS,
|
|
50
|
+
tools: read_tools)
|
|
51
|
+
@transport = ::MCP::Server::Transports::StdioTransport.new(@server)
|
|
52
|
+
@server.transport = @transport
|
|
53
|
+
refresh
|
|
54
|
+
@log.puts "asterism mcp: #{@net.node_id}/#{@net.app}; #{@call_tools.size} call tools" \
|
|
55
|
+
"#{@allow.empty? ? ' (no --allow: read-only)' : ''}"
|
|
56
|
+
@refresher = Thread.new { refresh_loop }
|
|
57
|
+
@transport.open
|
|
58
|
+
ensure
|
|
59
|
+
@refresher&.kill
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------ the list
|
|
63
|
+
|
|
64
|
+
def refresh_loop
|
|
65
|
+
loop do
|
|
66
|
+
sleep REFRESH_S
|
|
67
|
+
refresh
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
@log.puts "asterism mcp: refresh: #{e.class}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Makes the call tools again when the objects changed; tells the
|
|
74
|
+
# client when the list did.
|
|
75
|
+
def refresh
|
|
76
|
+
return if @allow.empty?
|
|
77
|
+
paths = @survey.objects.select { @allow.object?(_1) }
|
|
78
|
+
sig = paths.sort
|
|
79
|
+
return if sig == @signature
|
|
80
|
+
metas = @survey.metas(paths)
|
|
81
|
+
tools = {}
|
|
82
|
+
metas.sort.each do |path, methods|
|
|
83
|
+
methods.each do |m|
|
|
84
|
+
next unless @allow.method?(path, m["name"])
|
|
85
|
+
name = unique_name(tool_name(path, m["name"]), tools)
|
|
86
|
+
tools[name] = [path, m["name"], m["arity"], m]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
# Keep trying the objects whose meta did not come.
|
|
90
|
+
@signature = (paths - metas.keys).empty? ? sig : nil
|
|
91
|
+
changed = @lock.synchronize do
|
|
92
|
+
next false if tools == @call_tools
|
|
93
|
+
@call_tools = tools
|
|
94
|
+
list = read_tools + tools.map { |name, (path, _method, _arity, m)| call_tool(name, path, m) }
|
|
95
|
+
@server.tools = list.to_h { [_1.name_value, _1] }
|
|
96
|
+
@server.instance_variable_set(:@tool_names, @server.tools.keys)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
return unless changed
|
|
100
|
+
@log.puts "asterism mcp: call tools: #{tools.keys.join(', ')}"
|
|
101
|
+
@server.notify_tools_list_changed
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def tool_name(path, method)
|
|
105
|
+
"#{path.tr('/', '.')}.#{method}".gsub(/[^A-Za-z0-9_.-]/, "_")[0, 120]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def unique_name(name, taken)
|
|
109
|
+
return name unless taken.key?(name)
|
|
110
|
+
n = 2
|
|
111
|
+
n += 1 while taken.key?("#{name}_#{n}")
|
|
112
|
+
"#{name}_#{n}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------ tools
|
|
116
|
+
|
|
117
|
+
def read_tools
|
|
118
|
+
sv = @survey
|
|
119
|
+
bridge = self
|
|
120
|
+
[
|
|
121
|
+
::MCP::Tool.define(
|
|
122
|
+
name: "asterism_nodes",
|
|
123
|
+
description: "The nodes of the Asterism network, each with its profile (what runs it, a display name), " \
|
|
124
|
+
"its applications with their statuses, and the names of their objects.",
|
|
125
|
+
input_schema: { properties: {} },
|
|
126
|
+
annotations: { read_only_hint: true }
|
|
127
|
+
) do |server_context:|
|
|
128
|
+
bridge.answer { sv.tree(meta: false) }
|
|
129
|
+
end,
|
|
130
|
+
::MCP::Tool.define(
|
|
131
|
+
name: "asterism_objects",
|
|
132
|
+
description: "The exposed objects (<node>/<app>/<object>) and their methods with the number of " \
|
|
133
|
+
"arguments (-1: any), and the arguments' names and what each method does when the object " \
|
|
134
|
+
"tells them (args, doc). callable_as names the tool that calls a method, when this server " \
|
|
135
|
+
"allows it.",
|
|
136
|
+
input_schema: { properties: { pattern: { type: "string",
|
|
137
|
+
description: "node/app/object with * (one part) and ** " \
|
|
138
|
+
"(any parts); default **" } } },
|
|
139
|
+
annotations: { read_only_hint: true }
|
|
140
|
+
) do |server_context:, pattern: "**"|
|
|
141
|
+
bridge.answer { bridge.objects(pattern) }
|
|
142
|
+
end,
|
|
143
|
+
::MCP::Tool.define(
|
|
144
|
+
name: "asterism_status",
|
|
145
|
+
description: "The statuses the applications tell about themselves (asterism/<node>/<app>/@status): " \
|
|
146
|
+
"{\"<node>/<app>\": {...}}. An application without one is not listed.",
|
|
147
|
+
input_schema: { properties: { node: { type: "string" }, app: { type: "string" } } },
|
|
148
|
+
annotations: { read_only_hint: true }
|
|
149
|
+
) do |server_context:, node: nil, app: nil|
|
|
150
|
+
bridge.answer do
|
|
151
|
+
sv.statuses.select do |k, _|
|
|
152
|
+
n, a = k.split("/")
|
|
153
|
+
(node.nil? || node == n) && (app.nil? || app == a)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def objects(pattern)
|
|
161
|
+
paths = @survey.objects(pattern)
|
|
162
|
+
metas = @survey.metas(paths)
|
|
163
|
+
by = @lock.synchronize { @call_tools.to_h { |name, (path, m, _)| [[path, m], name] } }
|
|
164
|
+
paths.map do |p|
|
|
165
|
+
methods = metas[p]&.map do |m|
|
|
166
|
+
name = by[[p, m["name"]]]
|
|
167
|
+
name ? m.merge("callable_as" => name) : m
|
|
168
|
+
end
|
|
169
|
+
h = { "path" => p, "methods" => methods }
|
|
170
|
+
h["doc"] = @survey.object_doc(p) if @survey.object_doc(p)
|
|
171
|
+
h
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def call_tool(name, path, meta)
|
|
176
|
+
method = meta["name"]
|
|
177
|
+
arity = meta["arity"]
|
|
178
|
+
names = input_names(meta)
|
|
179
|
+
props = {}
|
|
180
|
+
required = []
|
|
181
|
+
if names
|
|
182
|
+
names.each do |n|
|
|
183
|
+
props[n.to_sym] = { description: "the argument #{n}" }
|
|
184
|
+
required << n if arity >= 0
|
|
185
|
+
end
|
|
186
|
+
props[:args] = { type: "array", description: "more positional arguments, if any" } if arity.negative?
|
|
187
|
+
elsif arity.negative?
|
|
188
|
+
props[:args] = { type: "array", description: "the positional arguments (any number)" }
|
|
189
|
+
else
|
|
190
|
+
arity.times do |i|
|
|
191
|
+
props[:"arg#{i + 1}"] = { description: "argument #{i + 1} (any JSON value)" }
|
|
192
|
+
required << "arg#{i + 1}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
props[:kwargs] = { type: "object", description: "keyword arguments, if the method takes any" }
|
|
196
|
+
schema = { properties: props }
|
|
197
|
+
schema[:required] = required unless required.empty?
|
|
198
|
+
about = [meta["doc"], (d = @survey.object_doc(path)) && "(#{path}: #{d})"].compact
|
|
199
|
+
count = names ? "" : " (#{arity.negative? ? 'any number of' : arity} arguments)"
|
|
200
|
+
bridge = self
|
|
201
|
+
::MCP::Tool.define(
|
|
202
|
+
name: name,
|
|
203
|
+
description: "#{about.empty? ? '' : "#{about.join(' ')}\n"}Calls #{method} on the Asterism object " \
|
|
204
|
+
"#{path}#{count} and returns {\"value\": ...}.",
|
|
205
|
+
input_schema: schema
|
|
206
|
+
) do |server_context:, **args|
|
|
207
|
+
bridge.invoke(path, method, arity, args, names)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The meta's argument names as inputs, or nil (none, or a name the
|
|
212
|
+
# inputs cannot take: "args", "kwargs", a repeated one).
|
|
213
|
+
def input_names(meta)
|
|
214
|
+
names = meta["args"]
|
|
215
|
+
return nil unless names.is_a?(Array) && !names.empty?
|
|
216
|
+
return nil if names.uniq.size != names.size || names.any? { %w[args kwargs].include?(_1) }
|
|
217
|
+
names
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A call tool's call.
|
|
221
|
+
def invoke(path, method, arity, args, names = nil)
|
|
222
|
+
pos = if names
|
|
223
|
+
names.map { args[_1.to_sym] } + (arity.negative? ? Array(args[:args]) : [])
|
|
224
|
+
elsif arity.negative?
|
|
225
|
+
Array(args[:args])
|
|
226
|
+
else
|
|
227
|
+
(1..arity).map { args[:"arg#{_1}"] }
|
|
228
|
+
end
|
|
229
|
+
if names && arity.negative?
|
|
230
|
+
# Optional named arguments: the trailing ones not given are left out.
|
|
231
|
+
pos.pop while !pos.empty? && pos.last.nil? && args[:args].nil?
|
|
232
|
+
end
|
|
233
|
+
kw = (args[:kwargs] || {}).to_h.transform_keys(&:to_sym)
|
|
234
|
+
unless @lock.synchronize { @call_tools.any? { |_, t| t[0] == path && t[1] == method } }
|
|
235
|
+
return error("#{path}.#{method} is not callable here (not allowed, or gone)")
|
|
236
|
+
end
|
|
237
|
+
v = ::Asterism[path, timeout: @survey.timeout].asterism_call(method, *pos, **kw)
|
|
238
|
+
text({ "value" => v })
|
|
239
|
+
rescue ::Asterism::RemoteError => e
|
|
240
|
+
error({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class, "message" => e.remote_message } })
|
|
241
|
+
rescue ::Asterism::Error => e
|
|
242
|
+
error({ "error" => { "kind" => "asterism", "class" => e.class.name, "message" => e.message } })
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The value of the block as a JSON text; an Asterism error as an
|
|
246
|
+
# error result.
|
|
247
|
+
def answer
|
|
248
|
+
text(yield)
|
|
249
|
+
rescue ::Asterism::Error => e
|
|
250
|
+
error("#{e.class}: #{e.message}")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def text(v)
|
|
254
|
+
::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(json_safe(v)) }])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def error(v)
|
|
258
|
+
::MCP::Tool::Response.new([{ type: "text", text: v.is_a?(String) ? v : JSON.generate(json_safe(v)) }],
|
|
259
|
+
error: true)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def json_safe(v)
|
|
263
|
+
case v
|
|
264
|
+
when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
|
|
265
|
+
when Array then v.map { json_safe(_1) }
|
|
266
|
+
when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
|
|
267
|
+
else v
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,396 @@
|
|
|
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
|
+
# ------------------------------------------------------------ status
|
|
121
|
+
|
|
122
|
+
def cmd_status_get(rest)
|
|
123
|
+
path = app_path!(rest, "status get")
|
|
124
|
+
code = 0
|
|
125
|
+
connect do |_net, sv|
|
|
126
|
+
st = sv.statuses([path])[path]
|
|
127
|
+
if st.nil?
|
|
128
|
+
@err.puts no_status(path)
|
|
129
|
+
code = 1
|
|
130
|
+
elsif structured?
|
|
131
|
+
emit(json_safe(st))
|
|
132
|
+
else
|
|
133
|
+
Text.status_lines(json_safe(st), style.width).each { @out.puts _1 }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
code
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The status, then each change as it comes (the text: what changed, as
|
|
140
|
+
# watch tells it); --once: the status now, once.
|
|
141
|
+
def cmd_status_echo(rest)
|
|
142
|
+
path = app_path!(rest, "status echo")
|
|
143
|
+
code = 0
|
|
144
|
+
connect do |net, sv|
|
|
145
|
+
q = Queue.new
|
|
146
|
+
subscribe_status(net, path) { |st| q << [Time.now, st] } unless @opts[:once]
|
|
147
|
+
st = sv.statuses([path])[path]
|
|
148
|
+
if st
|
|
149
|
+
echo_event(Time.now, path, st, true)
|
|
150
|
+
elsif @opts[:once]
|
|
151
|
+
@err.puts no_status(path)
|
|
152
|
+
code = 1
|
|
153
|
+
end
|
|
154
|
+
next if @opts[:once]
|
|
155
|
+
|
|
156
|
+
until_stopped(net) do |left|
|
|
157
|
+
got = q.pop(timeout: [left || 0.2, 0.2].min)
|
|
158
|
+
echo_event(got[0], path, got[1], false) if got
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
code
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# How often the status changes: every --period, when a change came, the
|
|
165
|
+
# average rate and the intervals (Hz).
|
|
166
|
+
def cmd_status_hz(rest)
|
|
167
|
+
path = app_path!(rest, "status hz")
|
|
168
|
+
raise ArgumentError, "--window: 1 or more" unless @opts[:window].positive?
|
|
169
|
+
raise ArgumentError, "--period: more than 0" unless @opts[:period].positive?
|
|
170
|
+
connect do |net, sv|
|
|
171
|
+
hz = Hz.new(window: @opts[:window])
|
|
172
|
+
lock = Mutex.new
|
|
173
|
+
subscribe_status(net, path) { |st| lock.synchronize { hz.put(st) } }
|
|
174
|
+
st = sv.statuses([path])[path]
|
|
175
|
+
lock.synchronize { hz.seed(st) } if st
|
|
176
|
+
@err.puts "asterism: #{path}: counting the status's changes (Ctrl-C to stop)" unless structured?
|
|
177
|
+
nxt = now + @opts[:period]
|
|
178
|
+
until_stopped(net) do |_left|
|
|
179
|
+
sleep 0.05
|
|
180
|
+
next if now < nxt
|
|
181
|
+
nxt += @opts[:period]
|
|
182
|
+
s = lock.synchronize { hz.report }
|
|
183
|
+
next unless s
|
|
184
|
+
if structured?
|
|
185
|
+
emit_line({ "t" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path }.merge(s))
|
|
186
|
+
else
|
|
187
|
+
Hz.text(s).each { @out.puts _1 }
|
|
188
|
+
end
|
|
189
|
+
@out.flush
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
0
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------ completion
|
|
196
|
+
|
|
197
|
+
def cmd_completion(rest)
|
|
198
|
+
case rest.shift
|
|
199
|
+
when "bash" then @out.write(Complete::BASH)
|
|
200
|
+
when "zsh" then @out.write(Complete::ZSH)
|
|
201
|
+
else raise ArgumentError, "completion: bash or zsh (source <(asterism completion bash))"
|
|
202
|
+
end
|
|
203
|
+
0
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The candidates for the word being completed, one a line. The words
|
|
207
|
+
# come as arguments (the last one the word being completed), after
|
|
208
|
+
# "--"; or (--bash LINE) as bash's line up to the cursor.
|
|
209
|
+
def cmd_complete(argv)
|
|
210
|
+
bash = argv.first == "--bash"
|
|
211
|
+
words = if bash
|
|
212
|
+
Complete.split_line(argv[1].to_s)
|
|
213
|
+
else
|
|
214
|
+
argv.first == "--" ? argv.drop(1) : argv
|
|
215
|
+
end
|
|
216
|
+
words = [""] if words.empty?
|
|
217
|
+
@complete_words = words
|
|
218
|
+
cands = Complete.new(self, CompleteSource.new(self, words)).candidates(words)
|
|
219
|
+
cands = Complete.bash_trim(cands, words.last) if bash && cands != [Complete::FILES]
|
|
220
|
+
cands.each { @out.puts _1 }
|
|
221
|
+
@out.flush
|
|
222
|
+
# A connection still trying past the budget is left behind: the
|
|
223
|
+
# process ends without waiting for it.
|
|
224
|
+
Process.exit!(0) if @complete_abandoned
|
|
225
|
+
0
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# What __complete asks the network for, through the cache: the names
|
|
229
|
+
# (the tokens) and an object's methods (its meta).
|
|
230
|
+
class CompleteSource
|
|
231
|
+
def initialize(cli, words)
|
|
232
|
+
@cli = cli
|
|
233
|
+
@words = words
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def names
|
|
237
|
+
cached("names") do
|
|
238
|
+
@cli.complete_fetch { |sv| { "apps" => sv.apps, "objects" => sv.objects } }
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def methods(path)
|
|
243
|
+
cached("methods #{path}") do
|
|
244
|
+
@cli.complete_fetch { |sv| sv.metas([path])[path]&.map { _1["name"] } }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def all_methods
|
|
249
|
+
cached("methods") do
|
|
250
|
+
@cli.complete_fetch { |sv| sv.metas(sv.objects).values.flatten.map { _1["name"] }.uniq.sort }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
def cached(entry, &blk)
|
|
257
|
+
net = @cli.complete_network(@words)
|
|
258
|
+
return nil if net.none?
|
|
259
|
+
dir = Complete::Cache.dir(@cli.env)
|
|
260
|
+
return blk.call unless dir
|
|
261
|
+
Complete::Cache.new(dir: dir, network: net).fetch(entry, &blk)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
attr_reader :env
|
|
266
|
+
|
|
267
|
+
# Where the network is for __complete: the options among the words,
|
|
268
|
+
# else the environment (network). [router, credentials (its full path),
|
|
269
|
+
# peer]
|
|
270
|
+
def complete_network(words)
|
|
271
|
+
words.each_cons(2) do |a, b|
|
|
272
|
+
case a
|
|
273
|
+
when "--router" then @opts[:router] = b
|
|
274
|
+
when "--credentials" then @opts[:credentials] = b
|
|
275
|
+
when "--peer" then @opts[:peer] = b
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
r, c, pr = network
|
|
279
|
+
[r, c && File.expand_path(c), pr]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Connects for at most Complete::BUDGET seconds and yields the Survey;
|
|
283
|
+
# the block's value, or nil when the network did not answer in time.
|
|
284
|
+
def complete_fetch
|
|
285
|
+
@opts[:settle] = 0.6
|
|
286
|
+
@opts[:timeout] = 0.6
|
|
287
|
+
out = Queue.new
|
|
288
|
+
th = Thread.new do
|
|
289
|
+
err = @err
|
|
290
|
+
@err = File.open(File::NULL, "w")
|
|
291
|
+
begin
|
|
292
|
+
connect { |_net, sv| out << yield(sv) }
|
|
293
|
+
rescue StandardError, ScriptError
|
|
294
|
+
out << nil
|
|
295
|
+
ensure
|
|
296
|
+
@err = err
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
v = out.pop(timeout: Complete::BUDGET)
|
|
300
|
+
if v.nil? && th.alive?
|
|
301
|
+
@complete_abandoned = true
|
|
302
|
+
return nil
|
|
303
|
+
end
|
|
304
|
+
th.join(0.5) || (@complete_abandoned = true)
|
|
305
|
+
v
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# ------------------------------------------------------------ helpers
|
|
309
|
+
|
|
310
|
+
def app_path!(rest, what)
|
|
311
|
+
path = rest.shift
|
|
312
|
+
ok = path && path.split("/", -1).size == 2 && path.split("/").none? { _1.empty? || _1.include?("*") }
|
|
313
|
+
raise ArgumentError, "#{what}: which application? (asterism #{what} NODE/APP)" unless ok
|
|
314
|
+
path
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def no_status(path)
|
|
318
|
+
"asterism: no status from #{path} (not there, no status, or no answer in time)"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# node/app[/object] painted part by part.
|
|
322
|
+
def paint_path(path)
|
|
323
|
+
parts = path.split("/")
|
|
324
|
+
roles = %i[node app object]
|
|
325
|
+
parts.each_with_index.map { |p, i| paint(p, roles[i] || :object) }.join(paint("/", :note))
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# An application (a tree's entry) under its name: its status and its
|
|
329
|
+
# objects (how many methods, and the object's description).
|
|
330
|
+
def app_block(name, a, indent)
|
|
331
|
+
pad = " " * indent
|
|
332
|
+
@out.puts "#{pad}#{paint(name, :app)}"
|
|
333
|
+
status_block(a, indent + 2)
|
|
334
|
+
return if a["objects"].empty?
|
|
335
|
+
@out.puts "#{pad} #{paint('objects:', :note)}"
|
|
336
|
+
a["objects"].each do |o|
|
|
337
|
+
ms = o["methods"]
|
|
338
|
+
@out.puts "#{pad} #{paint(o['name'], :object)} #{paint(ms ? count(ms.size, 'method') : '(no meta)', :note)}"
|
|
339
|
+
Text.wrap_text(o["doc"], style.width, first: "#{pad} ").each { @out.puts _1 } if o["doc"]
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# The status's puts of one application, cleaned (Status.parse).
|
|
344
|
+
def subscribe_status(net, path)
|
|
345
|
+
net.session.subscribe("asterism/#{path}/@status") do |sample|
|
|
346
|
+
got = ::Asterism::Status.parse(sample.key, sample.payload)
|
|
347
|
+
yield got[2] if got
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Yields until Ctrl-C, --duration or a lost connection (the seconds
|
|
352
|
+
# left of --duration, or nil).
|
|
353
|
+
def until_stopped(net)
|
|
354
|
+
deadline = @opts[:duration] && (now + @opts[:duration])
|
|
355
|
+
loop do
|
|
356
|
+
left = deadline && (deadline - now)
|
|
357
|
+
break if left && left <= 0
|
|
358
|
+
break unless net.connected?
|
|
359
|
+
yield left
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def now
|
|
364
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# One record of status echo: JSON / YAML, or the text (the status whole
|
|
368
|
+
# the first time, then a line for each change).
|
|
369
|
+
def echo_event(t, path, status, first)
|
|
370
|
+
status = json_safe(status)
|
|
371
|
+
if structured?
|
|
372
|
+
h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path, "status" => status }
|
|
373
|
+
h["now"] = true if first
|
|
374
|
+
emit_line(h)
|
|
375
|
+
else
|
|
376
|
+
text = echo_text(t, path, status, first)
|
|
377
|
+
@out.puts text if text
|
|
378
|
+
end
|
|
379
|
+
@out.flush
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# The text of one record of status echo (nil: no change).
|
|
383
|
+
def echo_text(t, path, status, first)
|
|
384
|
+
head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} "
|
|
385
|
+
old = @echo_last
|
|
386
|
+
@echo_last = status
|
|
387
|
+
if first || !old.is_a?(Hash) || !status.is_a?(Hash)
|
|
388
|
+
lines = Text.status_lines(status, style.width, Text.width(head))
|
|
389
|
+
return "#{head}#{paint(path, :app)}#{paint(' (now)', :note) if first}\n#{lines.join("\n")}"
|
|
390
|
+
end
|
|
391
|
+
changes = Text.status_diff(old, status)
|
|
392
|
+
return nil if changes.empty?
|
|
393
|
+
changes.map { |c| "#{head}#{change_text(c)}" }.join("\n")
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|