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.
@@ -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
@@ -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