mlld 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +96 -0
- data/lib/mlld.rb +722 -0
- metadata +45 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7f537533eef4e1fa1124f71eaa8c469622854b8907751e742fb54e5f9c528212
|
|
4
|
+
data.tar.gz: d26e2fa2a32b69a325d859baec356331e01b7550e009b233186fa83bde0c1614
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ad5a2a5b02bcca2ed625eb2f69a35a04d5efaa051e43b079cad8cddd69faed5567b3e3063238116fc5e96cd8a4eecfe5d287db3319821c4eb81429a38ee3e95c
|
|
7
|
+
data.tar.gz: 2190023a9b5dbdab5c5752f4abbaec07e55d5fa614efb3182d2046d249ff2cb0023faa63889788277ca35e482b24d5808108d932e4695179ea6989e7b9fbddd7
|
data/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# mlld Ruby SDK
|
|
2
|
+
|
|
3
|
+
Ruby wrapper for mlld using a persistent NDJSON RPC transport over `mlld live --stdio`.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Ruby 3.0+
|
|
8
|
+
- Node.js runtime
|
|
9
|
+
- `mlld` CLI available by command path
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
From this repo checkout:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem build mlld.gemspec
|
|
17
|
+
gem install ./mlld-*.gem
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
require 'mlld'
|
|
24
|
+
|
|
25
|
+
client = Mlld::Client.new
|
|
26
|
+
|
|
27
|
+
# Optional command override (local repo build example)
|
|
28
|
+
# client = Mlld::Client.new(command: 'node', command_args: ['./dist/cli.cjs'])
|
|
29
|
+
|
|
30
|
+
output = client.process('/show "Hello World"')
|
|
31
|
+
puts output
|
|
32
|
+
|
|
33
|
+
result = client.execute(
|
|
34
|
+
'./agent.mld',
|
|
35
|
+
{ 'text' => 'hello' },
|
|
36
|
+
state: { 'count' => 0 },
|
|
37
|
+
dynamic_modules: {
|
|
38
|
+
'@config' => { 'mode' => 'demo' }
|
|
39
|
+
},
|
|
40
|
+
timeout: 10
|
|
41
|
+
)
|
|
42
|
+
puts result.output
|
|
43
|
+
|
|
44
|
+
analysis = client.analyze('./module.mld')
|
|
45
|
+
p analysis.exports
|
|
46
|
+
|
|
47
|
+
client.close
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## In-Flight State Updates
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
handle = client.process_async(
|
|
54
|
+
"loop(99999, 50ms) until @state.exit [\n continue\n]\nshow \"done\"",
|
|
55
|
+
state: { 'exit' => false },
|
|
56
|
+
timeout: 10,
|
|
57
|
+
mode: 'strict'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
sleep 0.1
|
|
61
|
+
handle.update_state('exit', true)
|
|
62
|
+
puts handle.result
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## API
|
|
66
|
+
|
|
67
|
+
### Client
|
|
68
|
+
|
|
69
|
+
- `Mlld::Client.new(command: 'mlld', command_args: nil, timeout: 30.0, working_dir: nil)`
|
|
70
|
+
- `process(script, file_path: nil, payload: nil, state: nil, dynamic_modules: nil, dynamic_module_source: nil, mode: nil, allow_absolute_paths: nil, timeout: nil)`
|
|
71
|
+
- `process_async(...) -> Mlld::ProcessHandle`
|
|
72
|
+
- `execute(filepath, payload = nil, state: nil, dynamic_modules: nil, dynamic_module_source: nil, allow_absolute_paths: nil, mode: nil, timeout: nil)`
|
|
73
|
+
- `execute_async(...) -> Mlld::ExecuteHandle`
|
|
74
|
+
- `analyze(filepath)`
|
|
75
|
+
- `close`
|
|
76
|
+
|
|
77
|
+
### Handle Methods
|
|
78
|
+
|
|
79
|
+
- `request_id`
|
|
80
|
+
- `cancel`
|
|
81
|
+
- `update_state(path, value, timeout: nil)`
|
|
82
|
+
- `wait`
|
|
83
|
+
- `result`
|
|
84
|
+
|
|
85
|
+
### Convenience Functions
|
|
86
|
+
|
|
87
|
+
- `Mlld.process(...)`
|
|
88
|
+
- `Mlld.process_async(...)`
|
|
89
|
+
- `Mlld.execute(...)`
|
|
90
|
+
- `Mlld.execute_async(...)`
|
|
91
|
+
- `Mlld.analyze(...)`
|
|
92
|
+
|
|
93
|
+
## Notes
|
|
94
|
+
|
|
95
|
+
- Each `Client` keeps one live RPC subprocess for repeated calls.
|
|
96
|
+
- `ExecuteResult.state_writes` merges final-result writes and streamed `state:write` events.
|
data/lib/mlld.rb
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'thread'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
module Mlld
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :code, :returncode
|
|
11
|
+
|
|
12
|
+
def initialize(message, code: nil, returncode: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@code = code
|
|
15
|
+
@returncode = returncode
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
StateWrite = Struct.new(:path, :value, :timestamp, keyword_init: true)
|
|
20
|
+
Metrics = Struct.new(:total_ms, :parse_ms, :evaluate_ms, keyword_init: true)
|
|
21
|
+
Effect = Struct.new(:type, :content, :security, keyword_init: true)
|
|
22
|
+
ExecuteResult = Struct.new(:output, :state_writes, :exports, :effects, :metrics, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
Executable = Struct.new(:name, :params, :labels, keyword_init: true)
|
|
25
|
+
Import = Struct.new(:from, :names, keyword_init: true)
|
|
26
|
+
Guard = Struct.new(:name, :timing, :label, keyword_init: true)
|
|
27
|
+
Needs = Struct.new(:cmd, :node, :py, keyword_init: true)
|
|
28
|
+
AnalysisError = Struct.new(:message, :line, :column, keyword_init: true)
|
|
29
|
+
AnalyzeResult = Struct.new(
|
|
30
|
+
:filepath,
|
|
31
|
+
:valid,
|
|
32
|
+
:errors,
|
|
33
|
+
:executables,
|
|
34
|
+
:exports,
|
|
35
|
+
:imports,
|
|
36
|
+
:guards,
|
|
37
|
+
:needs,
|
|
38
|
+
keyword_init: true
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
class BaseHandle
|
|
42
|
+
attr_reader :request_id
|
|
43
|
+
|
|
44
|
+
def initialize(client:, request_id:, response_queue:, timeout:)
|
|
45
|
+
@client = client
|
|
46
|
+
@request_id = request_id
|
|
47
|
+
@response_queue = response_queue
|
|
48
|
+
@timeout = timeout
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
@complete = false
|
|
51
|
+
@raw_result = nil
|
|
52
|
+
@state_write_events = []
|
|
53
|
+
@error = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cancel
|
|
57
|
+
@client.send_cancel(@request_id)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_state(path, value, timeout: nil)
|
|
61
|
+
@client.send_state_update(@request_id, path, value, timeout || @timeout)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
protected
|
|
65
|
+
|
|
66
|
+
def await_raw
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
unless @complete
|
|
69
|
+
begin
|
|
70
|
+
@raw_result, @state_write_events = @client.await_request(
|
|
71
|
+
@request_id,
|
|
72
|
+
@response_queue,
|
|
73
|
+
@timeout
|
|
74
|
+
)
|
|
75
|
+
rescue Error => e
|
|
76
|
+
@error = e
|
|
77
|
+
end
|
|
78
|
+
@complete = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
raise @error if @error
|
|
82
|
+
raise Error.new('missing live result payload', code: 'TRANSPORT_ERROR') unless @raw_result
|
|
83
|
+
|
|
84
|
+
[@raw_result, @state_write_events]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class ProcessHandle < BaseHandle
|
|
90
|
+
def wait
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def result
|
|
95
|
+
response, = await_raw
|
|
96
|
+
output = response['output']
|
|
97
|
+
output = response.fetch('value', '') if output.nil?
|
|
98
|
+
output.is_a?(String) ? output : output.to_s
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class ExecuteHandle < BaseHandle
|
|
103
|
+
def wait
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def result
|
|
108
|
+
response, state_write_events = await_raw
|
|
109
|
+
@client.decode_execute_result(response, state_write_events)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class Client
|
|
114
|
+
attr_accessor :command, :command_args, :timeout, :working_dir
|
|
115
|
+
|
|
116
|
+
def initialize(command: 'mlld', command_args: nil, timeout: 30.0, working_dir: nil)
|
|
117
|
+
@command = command
|
|
118
|
+
@command_args = Array(command_args)
|
|
119
|
+
@timeout = timeout
|
|
120
|
+
@working_dir = working_dir
|
|
121
|
+
|
|
122
|
+
@lock = Mutex.new
|
|
123
|
+
@write_lock = Mutex.new
|
|
124
|
+
@stdin = nil
|
|
125
|
+
@stdout = nil
|
|
126
|
+
@stderr = nil
|
|
127
|
+
@wait_thr = nil
|
|
128
|
+
@reader_thread = nil
|
|
129
|
+
@stderr_thread = nil
|
|
130
|
+
@stderr_lines = []
|
|
131
|
+
@pending = {}
|
|
132
|
+
@request_id = 0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def close
|
|
136
|
+
stdin = nil
|
|
137
|
+
stdout = nil
|
|
138
|
+
stderr = nil
|
|
139
|
+
wait_thr = nil
|
|
140
|
+
reader_thread = nil
|
|
141
|
+
stderr_thread = nil
|
|
142
|
+
pending_queues = nil
|
|
143
|
+
|
|
144
|
+
@lock.synchronize do
|
|
145
|
+
stdin = @stdin
|
|
146
|
+
stdout = @stdout
|
|
147
|
+
stderr = @stderr
|
|
148
|
+
wait_thr = @wait_thr
|
|
149
|
+
reader_thread = @reader_thread
|
|
150
|
+
stderr_thread = @stderr_thread
|
|
151
|
+
pending_queues = @pending.values
|
|
152
|
+
|
|
153
|
+
@stdin = nil
|
|
154
|
+
@stdout = nil
|
|
155
|
+
@stderr = nil
|
|
156
|
+
@wait_thr = nil
|
|
157
|
+
@reader_thread = nil
|
|
158
|
+
@stderr_thread = nil
|
|
159
|
+
@pending = {}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
pending_queues&.each { |queue| queue << [:transport_error, Error.new('live transport closed', code: 'TRANSPORT_ERROR')] }
|
|
163
|
+
|
|
164
|
+
begin
|
|
165
|
+
stdin.close if stdin && !stdin.closed?
|
|
166
|
+
rescue StandardError
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if wait_thr&.alive?
|
|
170
|
+
begin
|
|
171
|
+
Process.kill('TERM', wait_thr.pid)
|
|
172
|
+
Timeout.timeout(1) { wait_thr.value }
|
|
173
|
+
rescue StandardError
|
|
174
|
+
begin
|
|
175
|
+
Process.kill('KILL', wait_thr.pid)
|
|
176
|
+
rescue StandardError
|
|
177
|
+
end
|
|
178
|
+
begin
|
|
179
|
+
wait_thr.value
|
|
180
|
+
rescue StandardError
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
begin
|
|
186
|
+
stdout.close if stdout && !stdout.closed?
|
|
187
|
+
rescue StandardError
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
stderr.close if stderr && !stderr.closed?
|
|
192
|
+
rescue StandardError
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
reader_thread&.join(1)
|
|
196
|
+
stderr_thread&.join(1)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def process(
|
|
200
|
+
script,
|
|
201
|
+
file_path: nil,
|
|
202
|
+
payload: nil,
|
|
203
|
+
state: nil,
|
|
204
|
+
dynamic_modules: nil,
|
|
205
|
+
dynamic_module_source: nil,
|
|
206
|
+
mode: nil,
|
|
207
|
+
allow_absolute_paths: nil,
|
|
208
|
+
timeout: nil
|
|
209
|
+
)
|
|
210
|
+
process_async(
|
|
211
|
+
script,
|
|
212
|
+
file_path: file_path,
|
|
213
|
+
payload: payload,
|
|
214
|
+
state: state,
|
|
215
|
+
dynamic_modules: dynamic_modules,
|
|
216
|
+
dynamic_module_source: dynamic_module_source,
|
|
217
|
+
mode: mode,
|
|
218
|
+
allow_absolute_paths: allow_absolute_paths,
|
|
219
|
+
timeout: timeout
|
|
220
|
+
).result
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def process_async(
|
|
224
|
+
script,
|
|
225
|
+
file_path: nil,
|
|
226
|
+
payload: nil,
|
|
227
|
+
state: nil,
|
|
228
|
+
dynamic_modules: nil,
|
|
229
|
+
dynamic_module_source: nil,
|
|
230
|
+
mode: nil,
|
|
231
|
+
allow_absolute_paths: nil,
|
|
232
|
+
timeout: nil
|
|
233
|
+
)
|
|
234
|
+
params = { 'script' => script }
|
|
235
|
+
params['filePath'] = file_path if file_path
|
|
236
|
+
params['payload'] = payload unless payload.nil?
|
|
237
|
+
params['state'] = state if state
|
|
238
|
+
params['dynamicModules'] = dynamic_modules if dynamic_modules
|
|
239
|
+
params['dynamicModuleSource'] = dynamic_module_source if dynamic_module_source
|
|
240
|
+
params['mode'] = mode if mode
|
|
241
|
+
params['allowAbsolutePaths'] = allow_absolute_paths unless allow_absolute_paths.nil?
|
|
242
|
+
|
|
243
|
+
request_id, response_queue = send_request('process', params)
|
|
244
|
+
ProcessHandle.new(
|
|
245
|
+
client: self,
|
|
246
|
+
request_id: request_id,
|
|
247
|
+
response_queue: response_queue,
|
|
248
|
+
timeout: resolve_timeout(timeout)
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def execute(
|
|
253
|
+
filepath,
|
|
254
|
+
payload = nil,
|
|
255
|
+
state: nil,
|
|
256
|
+
dynamic_modules: nil,
|
|
257
|
+
dynamic_module_source: nil,
|
|
258
|
+
allow_absolute_paths: nil,
|
|
259
|
+
mode: nil,
|
|
260
|
+
timeout: nil
|
|
261
|
+
)
|
|
262
|
+
execute_async(
|
|
263
|
+
filepath,
|
|
264
|
+
payload,
|
|
265
|
+
state: state,
|
|
266
|
+
dynamic_modules: dynamic_modules,
|
|
267
|
+
dynamic_module_source: dynamic_module_source,
|
|
268
|
+
allow_absolute_paths: allow_absolute_paths,
|
|
269
|
+
mode: mode,
|
|
270
|
+
timeout: timeout
|
|
271
|
+
).result
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def execute_async(
|
|
275
|
+
filepath,
|
|
276
|
+
payload = nil,
|
|
277
|
+
state: nil,
|
|
278
|
+
dynamic_modules: nil,
|
|
279
|
+
dynamic_module_source: nil,
|
|
280
|
+
allow_absolute_paths: nil,
|
|
281
|
+
mode: nil,
|
|
282
|
+
timeout: nil
|
|
283
|
+
)
|
|
284
|
+
params = { 'filepath' => filepath }
|
|
285
|
+
params['payload'] = payload unless payload.nil?
|
|
286
|
+
params['state'] = state if state
|
|
287
|
+
params['dynamicModules'] = dynamic_modules if dynamic_modules
|
|
288
|
+
params['dynamicModuleSource'] = dynamic_module_source if dynamic_module_source
|
|
289
|
+
params['allowAbsolutePaths'] = allow_absolute_paths unless allow_absolute_paths.nil?
|
|
290
|
+
params['mode'] = mode if mode
|
|
291
|
+
|
|
292
|
+
request_id, response_queue = send_request('execute', params)
|
|
293
|
+
ExecuteHandle.new(
|
|
294
|
+
client: self,
|
|
295
|
+
request_id: request_id,
|
|
296
|
+
response_queue: response_queue,
|
|
297
|
+
timeout: resolve_timeout(timeout)
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def analyze(filepath)
|
|
302
|
+
result, = request('analyze', { 'filepath' => filepath }, nil)
|
|
303
|
+
build_analyze_result(result, filepath)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def send_cancel(request_id)
|
|
307
|
+
send_control_request({ 'method' => 'cancel', 'id' => request_id })
|
|
308
|
+
rescue Error
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def send_state_update(request_id, path, value, timeout)
|
|
313
|
+
unless path.is_a?(String) && !path.strip.empty?
|
|
314
|
+
raise Error.new('state update path is required', code: 'INVALID_REQUEST')
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
resolved_timeout = resolve_timeout(timeout)
|
|
318
|
+
max_wait = resolved_timeout || 2.0
|
|
319
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + max_wait
|
|
320
|
+
|
|
321
|
+
loop do
|
|
322
|
+
begin
|
|
323
|
+
request('state:update', {
|
|
324
|
+
'requestId' => request_id,
|
|
325
|
+
'path' => path,
|
|
326
|
+
'value' => value
|
|
327
|
+
}, resolved_timeout)
|
|
328
|
+
return nil
|
|
329
|
+
rescue Error => error
|
|
330
|
+
raise unless error.code == 'REQUEST_NOT_FOUND'
|
|
331
|
+
raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
332
|
+
|
|
333
|
+
sleep(0.025)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def await_request(request_id, response_queue, timeout)
|
|
339
|
+
state_write_events = []
|
|
340
|
+
deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
|
|
341
|
+
|
|
342
|
+
loop do
|
|
343
|
+
remaining = deadline ? deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
|
|
344
|
+
if remaining && remaining <= 0
|
|
345
|
+
remove_pending(request_id)
|
|
346
|
+
send_cancel(request_id)
|
|
347
|
+
raise Error.new("request timeout after #{timeout}s", code: 'TIMEOUT')
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
entry = nil
|
|
351
|
+
if remaining
|
|
352
|
+
begin
|
|
353
|
+
entry = Timeout.timeout(remaining) { response_queue.pop }
|
|
354
|
+
rescue Timeout::Error
|
|
355
|
+
remove_pending(request_id)
|
|
356
|
+
send_cancel(request_id)
|
|
357
|
+
raise Error.new("request timeout after #{timeout}s", code: 'TIMEOUT')
|
|
358
|
+
end
|
|
359
|
+
else
|
|
360
|
+
entry = response_queue.pop
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
kind, payload = entry
|
|
364
|
+
|
|
365
|
+
if kind == :event
|
|
366
|
+
state_write = state_write_from_event(payload)
|
|
367
|
+
state_write_events << state_write if state_write
|
|
368
|
+
next
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
raise payload if kind == :transport_error
|
|
372
|
+
next unless kind == :result && payload.is_a?(Hash)
|
|
373
|
+
|
|
374
|
+
error_payload = payload['error']
|
|
375
|
+
raise error_from_payload(error_payload) if error_payload.is_a?(Hash)
|
|
376
|
+
|
|
377
|
+
payload.delete('id')
|
|
378
|
+
return [payload, state_write_events]
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def decode_execute_result(result, state_write_events)
|
|
383
|
+
state_writes = Array(result['stateWrites']).map do |write|
|
|
384
|
+
next unless write.is_a?(Hash)
|
|
385
|
+
|
|
386
|
+
StateWrite.new(
|
|
387
|
+
path: write['path'].to_s,
|
|
388
|
+
value: write['value'],
|
|
389
|
+
timestamp: write['timestamp']
|
|
390
|
+
)
|
|
391
|
+
end.compact
|
|
392
|
+
|
|
393
|
+
state_writes = merge_state_writes(state_writes, state_write_events)
|
|
394
|
+
|
|
395
|
+
metrics_payload = result['metrics']
|
|
396
|
+
metrics = nil
|
|
397
|
+
if metrics_payload.is_a?(Hash)
|
|
398
|
+
metrics = Metrics.new(
|
|
399
|
+
total_ms: metrics_payload['totalMs'] || 0,
|
|
400
|
+
parse_ms: metrics_payload['parseMs'] || 0,
|
|
401
|
+
evaluate_ms: metrics_payload['evaluateMs'] || 0
|
|
402
|
+
)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
effects = Array(result['effects']).map do |effect|
|
|
406
|
+
next unless effect.is_a?(Hash)
|
|
407
|
+
|
|
408
|
+
Effect.new(
|
|
409
|
+
type: effect['type'].to_s,
|
|
410
|
+
content: effect['content'],
|
|
411
|
+
security: effect['security']
|
|
412
|
+
)
|
|
413
|
+
end.compact
|
|
414
|
+
|
|
415
|
+
ExecuteResult.new(
|
|
416
|
+
output: result['output'].to_s,
|
|
417
|
+
state_writes: state_writes,
|
|
418
|
+
exports: result.fetch('exports', []),
|
|
419
|
+
effects: effects,
|
|
420
|
+
metrics: metrics
|
|
421
|
+
)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
private
|
|
425
|
+
|
|
426
|
+
def request(method, params, timeout)
|
|
427
|
+
request_id, response_queue = send_request(method, params)
|
|
428
|
+
await_request(request_id, response_queue, timeout)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def send_request(method, params)
|
|
432
|
+
request_id = nil
|
|
433
|
+
response_queue = nil
|
|
434
|
+
stdin = nil
|
|
435
|
+
payload = nil
|
|
436
|
+
|
|
437
|
+
@lock.synchronize do
|
|
438
|
+
ensure_transport_locked
|
|
439
|
+
request_id = @request_id
|
|
440
|
+
@request_id += 1
|
|
441
|
+
|
|
442
|
+
response_queue = Queue.new
|
|
443
|
+
@pending[request_id] = response_queue
|
|
444
|
+
|
|
445
|
+
stdin = @stdin
|
|
446
|
+
unless stdin
|
|
447
|
+
@pending.delete(request_id)
|
|
448
|
+
raise Error.new('live transport stdin is unavailable', code: 'TRANSPORT_ERROR')
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
payload = JSON.generate({ 'method' => method, 'id' => request_id, 'params' => params })
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
@write_lock.synchronize do
|
|
455
|
+
stdin.write(payload)
|
|
456
|
+
stdin.write("\n")
|
|
457
|
+
stdin.flush
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
[request_id, response_queue]
|
|
461
|
+
rescue StandardError => e
|
|
462
|
+
remove_pending(request_id) if request_id
|
|
463
|
+
raise e if e.is_a?(Error)
|
|
464
|
+
|
|
465
|
+
raise Error.new("failed to send request: #{e}", code: 'TRANSPORT_ERROR')
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def send_control_request(payload)
|
|
469
|
+
stdin = @lock.synchronize { @stdin }
|
|
470
|
+
raise Error.new('live transport is unavailable', code: 'TRANSPORT_ERROR') unless stdin
|
|
471
|
+
|
|
472
|
+
@write_lock.synchronize do
|
|
473
|
+
stdin.write(JSON.generate(payload))
|
|
474
|
+
stdin.write("\n")
|
|
475
|
+
stdin.flush
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def remove_pending(request_id)
|
|
480
|
+
@lock.synchronize { @pending.delete(request_id) }
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def transport_running_locked?
|
|
484
|
+
@wait_thr&.alive? && @reader_thread&.alive? && @stdin && !@stdin.closed?
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def ensure_transport_locked
|
|
488
|
+
return if transport_running_locked?
|
|
489
|
+
|
|
490
|
+
@stderr_lines = []
|
|
491
|
+
|
|
492
|
+
command = [@command, *@command_args, 'live', '--stdio']
|
|
493
|
+
options = {}
|
|
494
|
+
options[:chdir] = @working_dir if @working_dir
|
|
495
|
+
|
|
496
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*command, **options)
|
|
497
|
+
@reader_thread = Thread.new { reader_loop }
|
|
498
|
+
@stderr_thread = Thread.new { stderr_loop }
|
|
499
|
+
rescue StandardError => e
|
|
500
|
+
raise Error.new("failed to create live transport stdio pipes: #{e}", code: 'TRANSPORT_ERROR')
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def reader_loop
|
|
504
|
+
stdout = @lock.synchronize { @stdout }
|
|
505
|
+
return unless stdout
|
|
506
|
+
|
|
507
|
+
while (line = stdout.gets)
|
|
508
|
+
line = line.strip
|
|
509
|
+
next if line.empty?
|
|
510
|
+
|
|
511
|
+
envelope = nil
|
|
512
|
+
begin
|
|
513
|
+
envelope = JSON.parse(line)
|
|
514
|
+
rescue JSON::ParserError => e
|
|
515
|
+
fail_all_pending(Error.new("invalid live response: #{e.message}", code: 'TRANSPORT_ERROR'))
|
|
516
|
+
next
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
event = envelope['event']
|
|
520
|
+
if event.is_a?(Hash)
|
|
521
|
+
event_request_id = request_id_from_payload(event['id'])
|
|
522
|
+
if event_request_id
|
|
523
|
+
queue = @lock.synchronize { @pending[event_request_id] }
|
|
524
|
+
queue << [:event, event] if queue
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
result = envelope['result']
|
|
529
|
+
if result.is_a?(Hash)
|
|
530
|
+
result_request_id = request_id_from_payload(result['id'])
|
|
531
|
+
if result_request_id
|
|
532
|
+
queue = @lock.synchronize { @pending.delete(result_request_id) }
|
|
533
|
+
queue << [:result, result] if queue
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
ensure
|
|
538
|
+
returncode = @wait_thr&.value&.exitstatus
|
|
539
|
+
message = @stderr_lines.join.strip
|
|
540
|
+
message = 'live transport closed' if message.empty?
|
|
541
|
+
fail_all_pending(Error.new(message, code: 'TRANSPORT_ERROR', returncode: returncode))
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def stderr_loop
|
|
545
|
+
stderr = @lock.synchronize { @stderr }
|
|
546
|
+
return unless stderr
|
|
547
|
+
|
|
548
|
+
stderr.each_line do |line|
|
|
549
|
+
@stderr_lines << line
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def fail_all_pending(error)
|
|
554
|
+
pending_queues = @lock.synchronize do
|
|
555
|
+
queues = @pending.values
|
|
556
|
+
@pending = {}
|
|
557
|
+
@stdin = nil
|
|
558
|
+
@stdout = nil
|
|
559
|
+
@stderr = nil
|
|
560
|
+
@wait_thr = nil
|
|
561
|
+
queues
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
pending_queues.each do |queue|
|
|
565
|
+
queue << [:transport_error, error]
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def resolve_timeout(timeout)
|
|
570
|
+
return timeout unless timeout.nil?
|
|
571
|
+
|
|
572
|
+
@timeout
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def error_from_payload(error_payload)
|
|
576
|
+
Error.new(
|
|
577
|
+
error_payload.fetch('message', 'mlld request failed').to_s,
|
|
578
|
+
code: error_payload['code'].is_a?(String) ? error_payload['code'] : nil
|
|
579
|
+
)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def request_id_from_payload(value)
|
|
583
|
+
return value if value.is_a?(Integer)
|
|
584
|
+
return value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/)
|
|
585
|
+
|
|
586
|
+
nil
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def state_write_from_event(event)
|
|
590
|
+
return nil unless event['type'] == 'state:write'
|
|
591
|
+
|
|
592
|
+
write = event['write']
|
|
593
|
+
return nil unless write.is_a?(Hash)
|
|
594
|
+
|
|
595
|
+
path = write['path']
|
|
596
|
+
return nil unless path.is_a?(String) && !path.empty?
|
|
597
|
+
|
|
598
|
+
StateWrite.new(path: path, value: write['value'], timestamp: write['timestamp'])
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def merge_state_writes(primary, secondary)
|
|
602
|
+
return primary if secondary.empty?
|
|
603
|
+
return secondary if primary.empty?
|
|
604
|
+
|
|
605
|
+
merged = []
|
|
606
|
+
seen = {}
|
|
607
|
+
|
|
608
|
+
(primary + secondary).each do |state_write|
|
|
609
|
+
key = state_write_key(state_write)
|
|
610
|
+
next if seen[key]
|
|
611
|
+
|
|
612
|
+
seen[key] = true
|
|
613
|
+
merged << state_write
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
merged
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def state_write_key(state_write)
|
|
620
|
+
encoded_value = JSON.generate(state_write.value)
|
|
621
|
+
"#{state_write.path}|#{encoded_value}"
|
|
622
|
+
rescue StandardError
|
|
623
|
+
"#{state_write.path}|#{state_write.value.inspect}"
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def build_analyze_result(result, fallback_filepath)
|
|
627
|
+
errors = Array(result['errors']).map do |entry|
|
|
628
|
+
next unless entry.is_a?(Hash)
|
|
629
|
+
|
|
630
|
+
AnalysisError.new(
|
|
631
|
+
message: entry.fetch('message', '').to_s,
|
|
632
|
+
line: entry['line'],
|
|
633
|
+
column: entry['column']
|
|
634
|
+
)
|
|
635
|
+
end.compact
|
|
636
|
+
|
|
637
|
+
executables = Array(result['executables']).map do |entry|
|
|
638
|
+
next unless entry.is_a?(Hash)
|
|
639
|
+
|
|
640
|
+
Executable.new(
|
|
641
|
+
name: entry.fetch('name', '').to_s,
|
|
642
|
+
params: Array(entry['params']),
|
|
643
|
+
labels: Array(entry['labels'])
|
|
644
|
+
)
|
|
645
|
+
end.compact
|
|
646
|
+
|
|
647
|
+
imports = Array(result['imports']).map do |entry|
|
|
648
|
+
next unless entry.is_a?(Hash)
|
|
649
|
+
|
|
650
|
+
Import.new(
|
|
651
|
+
from: entry.fetch('from', '').to_s,
|
|
652
|
+
names: Array(entry['names'])
|
|
653
|
+
)
|
|
654
|
+
end.compact
|
|
655
|
+
|
|
656
|
+
guards = Array(result['guards']).map do |entry|
|
|
657
|
+
next unless entry.is_a?(Hash)
|
|
658
|
+
|
|
659
|
+
Guard.new(
|
|
660
|
+
name: entry.fetch('name', '').to_s,
|
|
661
|
+
timing: entry.fetch('timing', '').to_s,
|
|
662
|
+
label: entry['label']
|
|
663
|
+
)
|
|
664
|
+
end.compact
|
|
665
|
+
|
|
666
|
+
needs = nil
|
|
667
|
+
if result['needs'].is_a?(Hash)
|
|
668
|
+
needs = Needs.new(
|
|
669
|
+
cmd: Array(result.dig('needs', 'cmd')),
|
|
670
|
+
node: Array(result.dig('needs', 'node')),
|
|
671
|
+
py: Array(result.dig('needs', 'py'))
|
|
672
|
+
)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
AnalyzeResult.new(
|
|
676
|
+
filepath: result.fetch('filepath', fallback_filepath),
|
|
677
|
+
valid: result.fetch('valid', true),
|
|
678
|
+
errors: errors,
|
|
679
|
+
executables: executables,
|
|
680
|
+
exports: Array(result['exports']),
|
|
681
|
+
imports: imports,
|
|
682
|
+
guards: guards,
|
|
683
|
+
needs: needs
|
|
684
|
+
)
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
class << self
|
|
689
|
+
def default_client
|
|
690
|
+
@default_client ||= Client.new
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def close
|
|
694
|
+
return unless @default_client
|
|
695
|
+
|
|
696
|
+
@default_client.close
|
|
697
|
+
@default_client = nil
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def process(script, **kwargs)
|
|
701
|
+
default_client.process(script, **kwargs)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def process_async(script, **kwargs)
|
|
705
|
+
default_client.process_async(script, **kwargs)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def execute(filepath, payload = nil, **kwargs)
|
|
709
|
+
default_client.execute(filepath, payload, **kwargs)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def execute_async(filepath, payload = nil, **kwargs)
|
|
713
|
+
default_client.execute_async(filepath, payload, **kwargs)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def analyze(filepath)
|
|
717
|
+
default_client.analyze(filepath)
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
at_exit { Mlld.close }
|
metadata
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mlld
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 2.0.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- mlld-lang
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Persistent live --stdio SDK wrapper for mlld from Ruby.
|
|
13
|
+
email:
|
|
14
|
+
- opensource@mlld.dev
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- README.md
|
|
20
|
+
- lib/mlld.rb
|
|
21
|
+
homepage: https://github.com/mlld-lang/mlld
|
|
22
|
+
licenses:
|
|
23
|
+
- MIT
|
|
24
|
+
metadata:
|
|
25
|
+
homepage_uri: https://github.com/mlld-lang/mlld
|
|
26
|
+
source_code_uri: https://github.com/mlld-lang/mlld
|
|
27
|
+
changelog_uri: https://github.com/mlld-lang/mlld/blob/main/CHANGELOG.md
|
|
28
|
+
rdoc_options: []
|
|
29
|
+
require_paths:
|
|
30
|
+
- lib
|
|
31
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
32
|
+
requirements:
|
|
33
|
+
- - ">="
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '3.0'
|
|
36
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0'
|
|
41
|
+
requirements: []
|
|
42
|
+
rubygems_version: 4.0.3
|
|
43
|
+
specification_version: 4
|
|
44
|
+
summary: Ruby wrapper for the mlld CLI
|
|
45
|
+
test_files: []
|