melaya 0.1.2 → 0.2.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/LICENSE +158 -0
- data/README.md +244 -163
- data/lib/melaya/account.rb +30 -30
- data/lib/melaya/accounts.rb +42 -0
- data/lib/melaya/assistant.rb +41 -0
- data/lib/melaya/auth.rb +137 -0
- data/lib/melaya/backtest.rb +139 -101
- data/lib/melaya/billing.rb +75 -0
- data/lib/melaya/bugs.rb +67 -0
- data/lib/melaya/connectors.rb +71 -0
- data/lib/melaya/credentials.rb +255 -0
- data/lib/melaya/errors.rb +49 -15
- data/lib/melaya/evals.rb +78 -0
- data/lib/melaya/events.rb +425 -0
- data/lib/melaya/hitl.rb +114 -0
- data/lib/melaya/http_client.rb +226 -97
- data/lib/melaya/market.rb +198 -156
- data/lib/melaya/namespaces.rb +115 -0
- data/lib/melaya/phone.rb +75 -0
- data/lib/melaya/pipelines.rb +342 -0
- data/lib/melaya/projects.rb +58 -0
- data/lib/melaya/runner.rb +44 -0
- data/lib/melaya/sim.rb +110 -110
- data/lib/melaya/strategies.rb +170 -155
- data/lib/melaya/stream.rb +338 -336
- data/lib/melaya/team.rb +102 -0
- data/lib/melaya/templates.rb +148 -0
- data/lib/melaya/trade.rb +168 -168
- data/lib/melaya/version.rb +1 -1
- data/lib/melaya.rb +310 -79
- data/melaya.gemspec +28 -23
- metadata +32 -8
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "set"
|
|
8
|
+
require "thread"
|
|
9
|
+
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
|
|
12
|
+
module Melaya
|
|
13
|
+
# Platform real-time events via Socket.IO v4 at /api/v1/events.
|
|
14
|
+
#
|
|
15
|
+
# Implements the Engine.IO v4 handshake and long-poll transport (packets
|
|
16
|
+
# batched with the 0x1e record separator; server pings answered with a
|
|
17
|
+
# POSTed pong) and the Socket.IO v4 packet framing, mirroring the TS
|
|
18
|
+
# events.ts client.
|
|
19
|
+
#
|
|
20
|
+
# Room semantics (matching the server):
|
|
21
|
+
# - run:<runId> — pipeline run events
|
|
22
|
+
# - project:<project> — all events in a project (runs + CRUD)
|
|
23
|
+
# - hitl:user:<userId> — HITL approval events (joined automatically by server)
|
|
24
|
+
#
|
|
25
|
+
# Thread safety: all public methods are safe to call from any thread.
|
|
26
|
+
# Callbacks are invoked on a dedicated background reader thread — do not
|
|
27
|
+
# block in a callback.
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# events = Melaya::Events.new(api_key: ENV["MELAYA_API_KEY"])
|
|
31
|
+
#
|
|
32
|
+
# # Subscribe to a run
|
|
33
|
+
# unsub = events.on_run_update("run-123") { |e| puts e["event_type"] }
|
|
34
|
+
#
|
|
35
|
+
# # Subscribe to HITL approvals
|
|
36
|
+
# events.on_hitl_approval { |e| puts "HITL: #{e["type"]}" }
|
|
37
|
+
#
|
|
38
|
+
# # Clean up
|
|
39
|
+
# unsub.call # remove one subscription
|
|
40
|
+
# events.leave_run("run-123")
|
|
41
|
+
# events.close
|
|
42
|
+
#
|
|
43
|
+
# NOTE: Requires Ruby standard library 'net/http' with persistent HTTP/1.1.
|
|
44
|
+
# For production use the connection is kept alive between polls.
|
|
45
|
+
class Events
|
|
46
|
+
# Engine.IO v4 packet type characters
|
|
47
|
+
EIO_OPEN = "0"
|
|
48
|
+
EIO_CLOSE = "1"
|
|
49
|
+
EIO_PING = "2"
|
|
50
|
+
EIO_PONG = "3"
|
|
51
|
+
EIO_MESSAGE = "4"
|
|
52
|
+
EIO_UPGRADE = "5"
|
|
53
|
+
EIO_NOOP = "6"
|
|
54
|
+
|
|
55
|
+
# Socket.IO v4 packet types (numeric, follows the EIO_MESSAGE prefix)
|
|
56
|
+
SIO_CONNECT = 0
|
|
57
|
+
SIO_DISCONNECT = 1
|
|
58
|
+
SIO_EVENT = 2
|
|
59
|
+
SIO_ACK = 3
|
|
60
|
+
SIO_ERROR = 4
|
|
61
|
+
|
|
62
|
+
RECONNECT_BASE_DELAY = 2 # seconds — first reconnect wait
|
|
63
|
+
RECONNECT_MAX_DELAY = 30 # seconds — cap for exponential backoff
|
|
64
|
+
|
|
65
|
+
# @param api_key [String] mk_* key
|
|
66
|
+
# @param base_url [String]
|
|
67
|
+
# @param verify_ssl [Boolean]
|
|
68
|
+
def initialize(api_key:, base_url: HttpClient::DEFAULT_BASE_URL, verify_ssl: true)
|
|
69
|
+
raise ArgumentError, "Melaya: TLS certificate verification cannot be disabled." unless verify_ssl
|
|
70
|
+
@_tok = api_key.freeze
|
|
71
|
+
@base_url = base_url.sub(/\/$/, "")
|
|
72
|
+
@verify_ssl = true
|
|
73
|
+
|
|
74
|
+
@sid = nil
|
|
75
|
+
@closed = false
|
|
76
|
+
@listeners = Hash.new { |h, k| h[k] = [] } # event → [callable]
|
|
77
|
+
@joined_rooms = Set.new # rejoin-on-reconnect list
|
|
78
|
+
@room_listeners = Hash.new { |h, k| h[k] = [] } # room → [[event, callable]]
|
|
79
|
+
@mutex = Mutex.new
|
|
80
|
+
|
|
81
|
+
_connect_async
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ── Subscription helpers ───────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
# Subscribe to run events for a specific pipeline run.
|
|
87
|
+
# Joins the run:<runId> Socket.IO room automatically. When the payload
|
|
88
|
+
# carries a +runId+, events for other runs are filtered out so multiple
|
|
89
|
+
# per-run subscriptions never receive cross-room events.
|
|
90
|
+
# @param run_id [String]
|
|
91
|
+
# @yield [Hash] run push event
|
|
92
|
+
# @return [Proc] call to unsubscribe
|
|
93
|
+
def on_run_update(run_id, &block)
|
|
94
|
+
join_room("run:#{run_id}")
|
|
95
|
+
on("pushEvent", room: "run:#{run_id}", &run_filter(run_id, block))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Subscribe to init-phase progress events for a run.
|
|
99
|
+
# @param run_id [String]
|
|
100
|
+
# @yield [Hash]
|
|
101
|
+
# @return [Proc]
|
|
102
|
+
def on_init_phase(run_id, &block)
|
|
103
|
+
join_room("run:#{run_id}")
|
|
104
|
+
on("pushInitPhase", room: "run:#{run_id}", &run_filter(run_id, block))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Subscribe to all events in a project (runs + pipeline CRUD). When the
|
|
108
|
+
# payload carries a +projectId+/+project+, events for other projects are
|
|
109
|
+
# filtered out.
|
|
110
|
+
# @param project [String]
|
|
111
|
+
# @yield [Hash]
|
|
112
|
+
# @return [Proc]
|
|
113
|
+
def on_project_event(project, &block)
|
|
114
|
+
join_room("project:#{project}")
|
|
115
|
+
on("pushEvent", room: "project:#{project}", &project_filter(project, block))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Subscribe to HITL approval invalidation events.
|
|
119
|
+
# The server joins authenticated sockets to the hitl:user:<userId> room
|
|
120
|
+
# on connect — no explicit join needed.
|
|
121
|
+
# @yield [Hash]
|
|
122
|
+
# @return [Proc]
|
|
123
|
+
def on_hitl_approval(&block)
|
|
124
|
+
on("pushHitlApprovals", &block)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Subscribe to pipeline-created events within a project room.
|
|
128
|
+
# @param project [String]
|
|
129
|
+
# @yield [Hash]
|
|
130
|
+
# @return [Proc]
|
|
131
|
+
def on_pipeline_created(project, &block)
|
|
132
|
+
join_room("project:#{project}")
|
|
133
|
+
on("pipelineCreated", room: "project:#{project}", &project_filter(project, block))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Subscribe to pipeline-updated events within a project room.
|
|
137
|
+
# @param project [String]
|
|
138
|
+
# @yield [Hash]
|
|
139
|
+
# @return [Proc]
|
|
140
|
+
def on_pipeline_updated(project, &block)
|
|
141
|
+
join_room("project:#{project}")
|
|
142
|
+
on("pipelineUpdated", room: "project:#{project}", &project_filter(project, block))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Subscribe to pipeline-deleted events within a project room.
|
|
146
|
+
# @param project [String]
|
|
147
|
+
# @yield [Hash]
|
|
148
|
+
# @return [Proc]
|
|
149
|
+
def on_pipeline_deleted(project, &block)
|
|
150
|
+
join_room("project:#{project}")
|
|
151
|
+
on("pipelineDeleted", room: "project:#{project}", &project_filter(project, block))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Leave a run room, remove its listeners, and drop it from the
|
|
155
|
+
# rejoin-on-reconnect list.
|
|
156
|
+
# @param run_id [String]
|
|
157
|
+
def leave_run(run_id)
|
|
158
|
+
leave_room("run:#{run_id}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Leave a project room, remove its listeners, and drop it from the
|
|
162
|
+
# rejoin-on-reconnect list.
|
|
163
|
+
# @param project [String]
|
|
164
|
+
def leave_project(project)
|
|
165
|
+
leave_room("project:#{project}")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Close the Socket.IO connection and release all listeners.
|
|
169
|
+
def close
|
|
170
|
+
@mutex.synchronize do
|
|
171
|
+
@closed = true
|
|
172
|
+
@joined_rooms.clear
|
|
173
|
+
@room_listeners.clear
|
|
174
|
+
@listeners.clear
|
|
175
|
+
end
|
|
176
|
+
@poll_thread&.kill
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
# ── Internal event bus ─────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
# Register a listener. When +room:+ is given the listener is also indexed
|
|
184
|
+
# by room so leave_room can remove it.
|
|
185
|
+
def on(event, room: nil, &block)
|
|
186
|
+
@mutex.synchronize do
|
|
187
|
+
@listeners[event] << block
|
|
188
|
+
@room_listeners[room] << [event, block] if room
|
|
189
|
+
end
|
|
190
|
+
lambda do
|
|
191
|
+
@mutex.synchronize do
|
|
192
|
+
@listeners[event].delete(block)
|
|
193
|
+
@room_listeners[room].delete([event, block]) if room
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Wrap +block+ so it only fires for the given run. Payloads that carry a
|
|
199
|
+
# runId (RunPushEvent) are matched exactly; payloads without one pass
|
|
200
|
+
# through unchanged.
|
|
201
|
+
def run_filter(run_id, block)
|
|
202
|
+
lambda do |payload|
|
|
203
|
+
rid = payload.is_a?(Hash) ? (payload["runId"] || payload["run_id"]) : nil
|
|
204
|
+
block.call(payload) if rid.nil? || rid.to_s == run_id.to_s
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Wrap +block+ so it only fires for the given project. Payloads that carry
|
|
209
|
+
# a projectId/project are matched exactly; payloads without one pass
|
|
210
|
+
# through unchanged.
|
|
211
|
+
def project_filter(project, block)
|
|
212
|
+
lambda do |payload|
|
|
213
|
+
pid = payload.is_a?(Hash) ? (payload["projectId"] || payload["project"]) : nil
|
|
214
|
+
block.call(payload) if pid.nil? || pid.to_s == project.to_s
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def emit(event, payload)
|
|
219
|
+
listeners = @mutex.synchronize { @listeners[event].dup }
|
|
220
|
+
listeners.each do |l|
|
|
221
|
+
l.call(payload) rescue nil
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# ── Room management ────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
# Map a logical room string to the correct Socket.IO emit call.
|
|
228
|
+
#
|
|
229
|
+
# Server contract (/api/v1/events, server/src/index.ts):
|
|
230
|
+
# "run:<id>" → emit "joinRunRoom", bare runId (without "run:" prefix)
|
|
231
|
+
# "project:<name>"→ emit "joinProjectRoom", bare project name
|
|
232
|
+
# anything else → emit "joinRunRoom" with the full string (safe fallback)
|
|
233
|
+
#
|
|
234
|
+
# leave(room) always emits "leaveRoom" with the FULL room string.
|
|
235
|
+
def join_room(room)
|
|
236
|
+
already = @mutex.synchronize do
|
|
237
|
+
next true if @joined_rooms.include?(room)
|
|
238
|
+
@joined_rooms.add(room)
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
return if already
|
|
242
|
+
_emit_join(room) if @sid
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Remove the room from the rejoin-on-reconnect list AND detach every
|
|
246
|
+
# listener that was registered for it, then tell the server to leave.
|
|
247
|
+
def leave_room(room)
|
|
248
|
+
@mutex.synchronize do
|
|
249
|
+
@joined_rooms.delete(room)
|
|
250
|
+
(@room_listeners.delete(room) || []).each do |(event, block)|
|
|
251
|
+
@listeners[event].delete(block)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
sio_emit("leaveRoom", room) if @sid
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def replay_joins
|
|
258
|
+
rooms = @mutex.synchronize { @joined_rooms.to_a }
|
|
259
|
+
rooms.each { |room| _emit_join(room) }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Emit the correct server event for a join, mapping the room string to the
|
|
263
|
+
# appropriate event name and argument per the server protocol.
|
|
264
|
+
def _emit_join(room)
|
|
265
|
+
if room.start_with?("run:")
|
|
266
|
+
sio_emit("joinRunRoom", room[4..])
|
|
267
|
+
elsif room.start_with?("project:")
|
|
268
|
+
sio_emit("joinProjectRoom", room[8..])
|
|
269
|
+
else
|
|
270
|
+
# Safe fallback for unknown room prefixes (e.g. future room types).
|
|
271
|
+
sio_emit("joinRunRoom", room)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# ── Packet dispatch ────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
def handle_packet(raw)
|
|
278
|
+
return if raw.nil? || raw.empty?
|
|
279
|
+
type = raw[0]
|
|
280
|
+
case type
|
|
281
|
+
when EIO_PING
|
|
282
|
+
poll_post(EIO_PONG)
|
|
283
|
+
when EIO_MESSAGE
|
|
284
|
+
handle_sio_packet(raw[1..])
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def handle_sio_packet(sio_raw)
|
|
289
|
+
return if sio_raw.nil? || sio_raw.empty?
|
|
290
|
+
sio_type = sio_raw[0].to_i
|
|
291
|
+
return unless sio_type == SIO_EVENT
|
|
292
|
+
begin
|
|
293
|
+
args = JSON.parse(sio_raw[1..])
|
|
294
|
+
return unless args.is_a?(Array) && args.size >= 2
|
|
295
|
+
event, payload = args[0], args[1]
|
|
296
|
+
emit(event, payload)
|
|
297
|
+
rescue JSON::ParserError
|
|
298
|
+
# ignore malformed packets
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Engine.IO v4 polling batches packets separated by the 0x1e record
|
|
303
|
+
# separator. Split on it and dispatch each packet individually.
|
|
304
|
+
def parse_poll_response(text)
|
|
305
|
+
text.split("\x1e").each { |packet| handle_packet(packet) }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# ── Socket.IO framing ──────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
def sio_connect_packet
|
|
311
|
+
auth = JSON.generate({ "token" => @_tok })
|
|
312
|
+
"#{EIO_MESSAGE}#{SIO_CONNECT}#{auth}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def sio_emit(event, *args)
|
|
316
|
+
packet = "#{EIO_MESSAGE}#{SIO_EVENT}#{JSON.generate([event, *args])}"
|
|
317
|
+
poll_post(packet)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# ── HTTP polling transport ─────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
def poll_uri(extra = {})
|
|
323
|
+
params = {
|
|
324
|
+
"EIO" => "4",
|
|
325
|
+
"transport" => "polling"
|
|
326
|
+
}
|
|
327
|
+
params["sid"] = @sid if @sid
|
|
328
|
+
params.merge!(extra)
|
|
329
|
+
uri = URI.parse("#{@base_url}/api/v1/events/")
|
|
330
|
+
uri.query = URI.encode_www_form(params)
|
|
331
|
+
uri
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def http_for(uri)
|
|
335
|
+
h = Net::HTTP.new(uri.host, uri.port)
|
|
336
|
+
h.use_ssl = uri.scheme == "https"
|
|
337
|
+
h.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
338
|
+
h.open_timeout = 15
|
|
339
|
+
h.read_timeout = 60
|
|
340
|
+
h
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def poll_get
|
|
344
|
+
uri = poll_uri
|
|
345
|
+
req = Net::HTTP::Get.new(uri)
|
|
346
|
+
req["Authorization"] = "Bearer #{@_tok}"
|
|
347
|
+
resp = http_for(uri).request(req)
|
|
348
|
+
code = resp.code.to_i
|
|
349
|
+
if code >= 400
|
|
350
|
+
# HTTP 400 = "Session ID unknown": the sid is dead. Raising here makes
|
|
351
|
+
# _connect_loop perform a full re-handshake (and replay room joins)
|
|
352
|
+
# instead of silently polling a dead session forever.
|
|
353
|
+
raise MelayaError.new("MelayaEvents: poll failed (HTTP #{code})", status: code)
|
|
354
|
+
end
|
|
355
|
+
resp.body.to_s
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def poll_post(body_str)
|
|
359
|
+
uri = poll_uri
|
|
360
|
+
req = Net::HTTP::Post.new(uri)
|
|
361
|
+
req["Authorization"] = "Bearer #{@_tok}"
|
|
362
|
+
req["Content-Type"] = "text/plain;charset=UTF-8"
|
|
363
|
+
req.body = body_str
|
|
364
|
+
http_for(uri).request(req)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# ── Connection lifecycle ───────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
def _connect_async
|
|
370
|
+
@poll_thread = Thread.new do
|
|
371
|
+
Thread.current.abort_on_exception = false
|
|
372
|
+
_connect_loop
|
|
373
|
+
end
|
|
374
|
+
@poll_thread.name = "melaya-events-poll"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def _connect_loop
|
|
378
|
+
reconnect_attempt = 0
|
|
379
|
+
loop do
|
|
380
|
+
break if @mutex.synchronize { @closed }
|
|
381
|
+
begin
|
|
382
|
+
_handshake
|
|
383
|
+
reconnect_attempt = 0 # reset on successful connection
|
|
384
|
+
_poll_loop
|
|
385
|
+
rescue StandardError
|
|
386
|
+
# reconnect after bounded exponential backoff with ±25 % jitter
|
|
387
|
+
end
|
|
388
|
+
break if @mutex.synchronize { @closed }
|
|
389
|
+
reconnect_attempt += 1
|
|
390
|
+
base = [RECONNECT_BASE_DELAY * (2**(reconnect_attempt - 1)), RECONNECT_MAX_DELAY].min.to_f
|
|
391
|
+
jitter = base * 0.25 * (rand - 0.5) * 2
|
|
392
|
+
sleep([base + jitter, 0.5].max)
|
|
393
|
+
@sid = nil
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def _handshake
|
|
398
|
+
# Step 1: Engine.IO open handshake
|
|
399
|
+
text = poll_get
|
|
400
|
+
json_start = text.index("{")
|
|
401
|
+
raise MelayaError.new("MelayaEvents: unexpected EIO handshake", status: 0) if json_start.nil?
|
|
402
|
+
data = JSON.parse(text[json_start..])
|
|
403
|
+
@sid = data["sid"]
|
|
404
|
+
|
|
405
|
+
# Step 2: Socket.IO connect packet with auth
|
|
406
|
+
poll_post(sio_connect_packet)
|
|
407
|
+
|
|
408
|
+
# Replay room joins
|
|
409
|
+
replay_joins
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Continuous long-poll: the GET hangs server-side until data (or a ping)
|
|
413
|
+
# arrives, so we re-issue it immediately — never sleep between polls, or
|
|
414
|
+
# events queue up (and pings go unanswered) for the whole interval.
|
|
415
|
+
# Errors (including dead-session HTTP 400) propagate to _connect_loop,
|
|
416
|
+
# which backs off and re-handshakes.
|
|
417
|
+
def _poll_loop
|
|
418
|
+
loop do
|
|
419
|
+
break if @mutex.synchronize { @closed }
|
|
420
|
+
text = poll_get
|
|
421
|
+
parse_poll_response(text) unless text.empty?
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
data/lib/melaya/hitl.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Melaya
|
|
4
|
+
# HITL (Human-in-the-Loop) API — list, approve, and reject pending
|
|
5
|
+
# tool-call approval requests generated by running agent pipelines.
|
|
6
|
+
#
|
|
7
|
+
# Maps to /api/v1/private/hitl/*.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# pending = melaya.hitl.pending
|
|
11
|
+
# pending.each do |req|
|
|
12
|
+
# melaya.hitl.approve(req["requestId"], comment: "Looks good")
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# # Bulk operations
|
|
16
|
+
# melaya.hitl.bulk_decide(
|
|
17
|
+
# request_ids: ["r1", "r2"],
|
|
18
|
+
# decision: "approved",
|
|
19
|
+
# comment: "Batch OK"
|
|
20
|
+
# )
|
|
21
|
+
class HitlAPI
|
|
22
|
+
def initialize(http)
|
|
23
|
+
@http = http
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# GET /api/v1/private/hitl/approvals/pending
|
|
27
|
+
# List all pending HITL tool-call approvals for the authenticated user.
|
|
28
|
+
def pending
|
|
29
|
+
@http.get("/api/v1/private/hitl/approvals/pending")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# GET /api/v1/private/hitl/approvals/history
|
|
33
|
+
# List historical (decided) HITL approval records.
|
|
34
|
+
# @param limit [Integer, nil]
|
|
35
|
+
# @param offset [Integer, nil]
|
|
36
|
+
def history(limit: nil, offset: nil)
|
|
37
|
+
@http.get("/api/v1/private/hitl/approvals/history",
|
|
38
|
+
compact("limit" => limit, "offset" => offset))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# POST /api/v1/private/hitl/approvals/:requestId/approve
|
|
42
|
+
# Approve a pending HITL tool-call approval.
|
|
43
|
+
# @param request_id [String]
|
|
44
|
+
# @param comment [String, nil]
|
|
45
|
+
def approve(request_id, comment: nil)
|
|
46
|
+
body = compact("comment" => comment)
|
|
47
|
+
@http.post("/api/v1/private/hitl/approvals/#{enc(request_id)}/approve",
|
|
48
|
+
body.empty? ? nil : body)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# POST /api/v1/private/hitl/approvals/:requestId/reject
|
|
52
|
+
# Reject a pending HITL tool-call approval.
|
|
53
|
+
# @param request_id [String]
|
|
54
|
+
# @param comment [String, nil]
|
|
55
|
+
def reject(request_id, comment: nil)
|
|
56
|
+
body = compact("comment" => comment)
|
|
57
|
+
@http.post("/api/v1/private/hitl/approvals/#{enc(request_id)}/reject",
|
|
58
|
+
body.empty? ? nil : body)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# POST /api/v1/private/hitl/approvals/bulk
|
|
62
|
+
# Bulk approve or reject multiple pending tool calls in one call.
|
|
63
|
+
# @param request_ids [Array<String>]
|
|
64
|
+
# @param decision [String] "approved" or "rejected"
|
|
65
|
+
# @param comment [String, nil]
|
|
66
|
+
def bulk_decide(request_ids:, decision:, comment: nil)
|
|
67
|
+
body = compact(
|
|
68
|
+
"requestIds" => request_ids,
|
|
69
|
+
"decision" => decision,
|
|
70
|
+
"comment" => comment
|
|
71
|
+
)
|
|
72
|
+
@http.post("/api/v1/private/hitl/approvals/bulk", body)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# ── Run-level inspection ───────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
# GET /api/v1/private/hitl/runs/:runId/tool-stats
|
|
78
|
+
# Get tool-call statistics for a specific pipeline run.
|
|
79
|
+
def run_tool_stats(run_id)
|
|
80
|
+
@http.get("/api/v1/private/hitl/runs/#{enc(run_id)}/tool-stats")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# GET /api/v1/private/hitl/runs/:runId/tool-stats/by-agent
|
|
84
|
+
# Get tool-call stats broken down by agent for a run.
|
|
85
|
+
def run_tool_stats_by_agent(run_id)
|
|
86
|
+
@http.get("/api/v1/private/hitl/runs/#{enc(run_id)}/tool-stats/by-agent")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# GET /api/v1/private/hitl/runs/:runId/messages
|
|
90
|
+
# Get paginated messages for a pipeline run.
|
|
91
|
+
# @param limit [Integer, nil]
|
|
92
|
+
# @param cursor [String, nil]
|
|
93
|
+
def run_messages(run_id, limit: nil, cursor: nil)
|
|
94
|
+
@http.get("/api/v1/private/hitl/runs/#{enc(run_id)}/messages",
|
|
95
|
+
compact("limit" => limit, "cursor" => cursor))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# GET /api/v1/private/hitl/runs/:runId/tool-calls
|
|
99
|
+
# Get all tool calls for a pipeline run.
|
|
100
|
+
def run_tool_calls(run_id)
|
|
101
|
+
@http.get("/api/v1/private/hitl/runs/#{enc(run_id)}/tool-calls")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def enc(s)
|
|
107
|
+
URI.encode_www_form_component(s.to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def compact(hash)
|
|
111
|
+
hash.reject { |_, v| v.nil? }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|