opencode-ruby 0.0.1.alpha2

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,564 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "base64"
6
+
7
+ module Opencode
8
+ # HTTP client for OpenCode REST API.
9
+ # Thread safety: Each instance creates its own Net::HTTP connection.
10
+ # Do NOT share instances across threads. Create per-job.
11
+ class Client
12
+ attr_reader :directory
13
+
14
+ def initialize(
15
+ base_url: ENV["OPENCODE_BASE_URL"] || "http://localhost:4096",
16
+ password: ENV["OPENCODE_SERVER_PASSWORD"],
17
+ timeout: (ENV["OPENCODE_TIMEOUT"] || 120).to_i,
18
+ directory: nil,
19
+ workspace: nil
20
+ )
21
+ @uri = URI.parse(base_url)
22
+ @password = password
23
+ @timeout = timeout || 120
24
+ @directory = directory
25
+ @workspace = workspace
26
+ end
27
+
28
+ def create_session(title: nil, permissions: nil)
29
+ body = { title: title, permission: permissions }.compact
30
+ post("/session", body)
31
+ end
32
+
33
+ def send_message(
34
+ session_id, text,
35
+ parts: nil,
36
+ model: nil,
37
+ agent: nil,
38
+ system: nil,
39
+ message_id: nil,
40
+ no_reply: nil,
41
+ tools: nil,
42
+ format: nil,
43
+ variant: nil
44
+ )
45
+ body = prompt_payload(
46
+ text,
47
+ parts: parts,
48
+ model: model,
49
+ agent: agent,
50
+ system: system,
51
+ message_id: message_id,
52
+ no_reply: no_reply,
53
+ tools: tools,
54
+ format: format,
55
+ variant: variant
56
+ )
57
+ post("/session/#{session_id}/message", body)
58
+ end
59
+
60
+ def send_message_async(
61
+ session_id, text,
62
+ parts: nil,
63
+ model: nil,
64
+ agent: nil,
65
+ system: nil,
66
+ message_id: nil,
67
+ no_reply: nil,
68
+ tools: nil,
69
+ format: nil,
70
+ variant: nil
71
+ )
72
+ body = prompt_payload(
73
+ text,
74
+ parts: parts,
75
+ model: model,
76
+ agent: agent,
77
+ system: system,
78
+ message_id: message_id,
79
+ no_reply: no_reply,
80
+ tools: tools,
81
+ format: format,
82
+ variant: variant
83
+ )
84
+ post("/session/#{session_id}/prompt_async", body)
85
+ end
86
+
87
+ # Block-form streaming — the headline API for callers who want the
88
+ # full async-prompt + SSE-loop + final-exchange-merge flow in one
89
+ # call. Returns the final Opencode::Reply::Result value object once
90
+ # the agent finishes.
91
+ #
92
+ # reply = client.stream(session_id, "Explain monads") do |part|
93
+ # print part["content"] if part["type"] == "text"
94
+ # end
95
+ # reply.full_text # => the final accumulated text
96
+ # reply.tool_parts # => array of terminal tool parts
97
+ #
98
+ # The block is invoked every time a part is added, grows, finalizes,
99
+ # or (for tool parts) advances state — i.e., whenever a user-visible
100
+ # change happens. The block receives the current `part` hash (string
101
+ # keys: "type", "content", "tool", "status", "input", ...).
102
+ #
103
+ # If you need raw events (every server.* tick, todo.updated, prompt
104
+ # asked/replied, etc.), use #stream_events instead.
105
+ #
106
+ # Optional kwargs are forwarded to send_message_async — model, agent,
107
+ # system prompt override, and the SSE pacing knobs supported by
108
+ # stream_events.
109
+ def stream(
110
+ session_id, text,
111
+ model: nil, agent: nil, system: nil, message_id: nil,
112
+ stream_timeout: 600,
113
+ first_event_timeout: 120,
114
+ idle_stream_timeout: nil,
115
+ on_activity_tick: nil,
116
+ &block
117
+ )
118
+ send_message_async(
119
+ session_id, text,
120
+ model: model, agent: agent, system: system, message_id: message_id
121
+ )
122
+
123
+ reply = Opencode::Reply.new
124
+ reply.add_observer(StreamBlockObserver.new(&block)) if block_given?
125
+
126
+ stream_events(
127
+ session_id: session_id,
128
+ timeout: stream_timeout,
129
+ first_event_timeout: first_event_timeout,
130
+ idle_stream_timeout: idle_stream_timeout,
131
+ reply: reply,
132
+ on_activity_tick: on_activity_tick
133
+ ) do |event|
134
+ reply.apply(event)
135
+ end
136
+
137
+ merge_final_exchange(session_id, reply)
138
+ reply.result
139
+ end
140
+
141
+ def list_sessions
142
+ uri = build_uri("/session")
143
+ request = Net::HTTP::Get.new(uri)
144
+ execute(request)
145
+ end
146
+
147
+ def children(session_id)
148
+ uri = build_uri("/session/#{session_id}/children")
149
+ request = Net::HTTP::Get.new(uri)
150
+ execute(request)
151
+ end
152
+
153
+ def delete_session(session_id)
154
+ uri = build_uri("/session/#{session_id}")
155
+ request = Net::HTTP::Delete.new(uri)
156
+ execute(request)
157
+ end
158
+
159
+ def session_status
160
+ uri = build_uri("/session/status")
161
+ request = Net::HTTP::Get.new(uri)
162
+ execute(request)
163
+ end
164
+
165
+ def get_messages(session_id)
166
+ uri = build_uri("/session/#{session_id}/message")
167
+ request = Net::HTTP::Get.new(uri)
168
+ execute(request)
169
+ end
170
+
171
+ def abort_session(session_id)
172
+ post("/session/#{session_id}/abort", {})
173
+ end
174
+
175
+ def reply_question(request_id:, answers:)
176
+ post("/question/#{request_id}/reply", { answers: answers })
177
+ end
178
+
179
+ def reject_question(request_id:)
180
+ post("/question/#{request_id}/reject", {})
181
+ end
182
+
183
+ def reply_permission(request_id:, reply:, message: nil)
184
+ body = { reply: reply }
185
+ body[:message] = message if message.present?
186
+ post("/permission/#{request_id}/reply", body)
187
+ end
188
+
189
+ # Returns pending question requests as an Array of Hashes with
190
+ # SYMBOL keys, consistent with every other endpoint that flows
191
+ # through handle_response (e.g., health, list_sessions, get_messages).
192
+ # Callers that compare against persisted JSON column data should
193
+ # symbolize their side, not desymbolize this side.
194
+ def list_questions
195
+ uri = build_uri("/question")
196
+ request = Net::HTTP::Get.new(uri)
197
+ add_auth_header(request)
198
+
199
+ response = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do
200
+ http_client.request(request)
201
+ end
202
+
203
+ unless response.code.to_i.between?(200, 299)
204
+ raise ServerError, "list_questions failed: HTTP #{response.code} — #{response.body.to_s[0, 200]}"
205
+ end
206
+
207
+ return [] if response.body.blank?
208
+ JSON.parse(response.body, symbolize_names: true)
209
+ rescue JSON::ParserError => e
210
+ raise ServerError, "list_questions returned invalid JSON: #{e.message}"
211
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
212
+ raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}"
213
+ rescue Errno::ECONNREFUSED, SocketError => e
214
+ raise ConnectionError, "OpenCode unreachable: #{e.message}"
215
+ end
216
+
217
+ def health
218
+ uri = build_uri("/global/health", scoped: false)
219
+ request = Net::HTTP::Get.new(uri)
220
+ execute(request)
221
+ end
222
+
223
+ MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses
224
+ SSE_RECONNECT_DELAY = 0.1
225
+ TRANSIENT_SSE_ERRORS = [
226
+ EOFError,
227
+ IOError,
228
+ Net::OpenTimeout,
229
+ Net::ReadTimeout,
230
+ Errno::ECONNREFUSED,
231
+ Errno::ECONNRESET,
232
+ Errno::EPIPE
233
+ ].freeze
234
+
235
+ # Opens SSE connection to GET /event, yields parsed events filtered by session_id.
236
+ # Blocks until session goes idle or timeout, reconnecting across dropped
237
+ # event-stream connections.
238
+ #
239
+ # first_event_timeout: seconds to wait for a session-specific event before
240
+ # declaring the session stale. Server heartbeats don't count — they're global
241
+ # keep-alives that flow regardless of session state.
242
+ #
243
+ # Default 120s rather than the more aggressive 30s used originally:
244
+ # slow-thinking reasoning models (Kimi K2, GPT-5 with extended thinking,
245
+ # etc.) routinely spend 30-90s of pure reasoning before emitting their
246
+ # first `message.part.*` event, especially on cold sessions with long
247
+ # system prompts. 30s false-positive trips on legitimate first turns
248
+ # and converts them to `StaleSessionError`; 120s catches genuine zombies
249
+ # without nuking real reasoning. Callers that know their agent is
250
+ # short-prompt + fast can pass a lower value.
251
+ #
252
+ # idle_stream_timeout: seconds to wait BETWEEN meaningful events once
253
+ # the session has started producing them. Default nil = no check
254
+ # (preserves the overall `timeout` ceiling behavior). Opt-in heartbeat
255
+ # watchdog for callers whose user-facing surface needs to fail fast
256
+ # rather than sit forever when an upstream LLM stream wedges mid-turn.
257
+ # Distinct from first_event_timeout (which only protects cold-start)
258
+ # and from the overall `timeout` ceiling of 600s (which is forgiving
259
+ # — a hung stream holding a thread for 10 minutes is already a bad
260
+ # UX). When the window is exceeded the call raises
261
+ # Opencode::IdleStreamError, which the caller is expected to catch and
262
+ # translate into a user-visible error / retry affordance.
263
+ def stream_events(session_id:, timeout: 600, first_event_timeout: 120,
264
+ idle_stream_timeout: nil,
265
+ reply: nil, on_activity_tick: nil, &block)
266
+ uri = build_uri("/event")
267
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
268
+ first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout
269
+ received_session_event = false
270
+ last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
271
+
272
+ loop do
273
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
274
+ deadline = check_deadline_or_suspend(now, deadline, timeout, reply)
275
+
276
+ # NOTE: first_event_deadline is *not* suspension-eligible. If the agent
277
+ # never gets started we want to fail fast — a session that's blocked on
278
+ # a prompt has, by definition, already produced events.
279
+ if !received_session_event && now > first_event_deadline
280
+ raise StaleSessionError, "No events for session #{session_id} within #{first_event_timeout}s"
281
+ end
282
+
283
+ if idle_stream_timeout && received_session_event &&
284
+ (now - last_meaningful_event_at) > idle_stream_timeout
285
+ raise IdleStreamError,
286
+ "No meaningful events for session #{session_id} within #{idle_stream_timeout}s " \
287
+ "(SSE heartbeats still arriving — upstream likely wedged mid-turn)"
288
+ end
289
+
290
+ request = Net::HTTP::Get.new(uri)
291
+ request["Accept"] = "text/event-stream"
292
+ request["Cache-Control"] = "no-cache"
293
+ add_auth_header(request)
294
+
295
+ http = Net::HTTP.new(@uri.host, @uri.port)
296
+ http.use_ssl = @uri.scheme == "https"
297
+ http.open_timeout = 10
298
+ http.read_timeout = 30
299
+
300
+ begin
301
+ buffer = String.new
302
+
303
+ http.request(request) do |response|
304
+ unless response.is_a?(Net::HTTPSuccess)
305
+ raise ServerError, "SSE connection failed: HTTP #{response.code}"
306
+ end
307
+
308
+ response.read_body do |chunk|
309
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
310
+ deadline = check_deadline_or_suspend(now, deadline, timeout, reply)
311
+
312
+ if !received_session_event && now > first_event_deadline
313
+ raise StaleSessionError, "No events for session #{session_id} within #{first_event_timeout}s"
314
+ end
315
+
316
+ if idle_stream_timeout && received_session_event &&
317
+ (now - last_meaningful_event_at) > idle_stream_timeout
318
+ raise IdleStreamError,
319
+ "No meaningful events for session #{session_id} within #{idle_stream_timeout}s " \
320
+ "(SSE heartbeats still arriving — upstream likely wedged mid-turn)"
321
+ end
322
+
323
+ buffer << chunk
324
+ if buffer.bytesize > MAX_SSE_BUFFER
325
+ raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes"
326
+ end
327
+
328
+ while (idx = buffer.index("\n\n"))
329
+ raw_event = buffer.slice!(0, idx + 2)
330
+ event = parse_sse_event(raw_event, session_id)
331
+ next unless event
332
+
333
+ unless event[:type]&.start_with?("server.")
334
+ received_session_event = true
335
+ last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
336
+ end
337
+
338
+ # Tick activity on EVERY event, including server.heartbeat —
339
+ # that's the whole point: a healthy long wait (user thinking
340
+ # for 30 minutes) keeps the container warm via heartbeats so
341
+ # the reaper doesn't kill it mid-wait.
342
+ on_activity_tick&.call(event)
343
+ block.call(event)
344
+ return if event[:type] == "session.idle"
345
+ end
346
+ end
347
+ end
348
+ rescue *TRANSIENT_SSE_ERRORS
349
+ # Treat transport-level SSE disconnects like clean EOF: reconnect
350
+ # until session.idle, the overall timeout, or first-event timeout.
351
+ ensure
352
+ begin
353
+ http&.finish if http&.started?
354
+ rescue IOError
355
+ # Connection already closed — network partition or server shutdown
356
+ end
357
+ end
358
+
359
+ cutoff = received_session_event ? deadline : first_event_deadline
360
+ sleep_for = [ SSE_RECONNECT_DELAY, cutoff - Process.clock_gettime(Process::CLOCK_MONOTONIC) ].min
361
+ if sleep_for.positive?
362
+ sleep sleep_for
363
+ end
364
+ end
365
+ end
366
+
367
+ def close
368
+ @http&.finish if @http&.started?
369
+ rescue IOError
370
+ # already closed
371
+ end
372
+
373
+ private
374
+
375
+ # Best-effort merge of the polled message exchange into the live
376
+ # reply. Catches the stream-only / poll-only asymmetry — todo.updated
377
+ # is poll-only on some opencode versions; pure-streaming would miss
378
+ # the terminal todo state otherwise. If the session API is also down
379
+ # at this point (network partition, container teardown mid-call), we
380
+ # silently keep whatever the stream accumulated rather than raising;
381
+ # the caller's reply is still a usable Result either way.
382
+ def merge_final_exchange(session_id, reply)
383
+ exchange = get_messages(session_id)
384
+ last_assistant = Array(exchange).reverse_each.find do |message|
385
+ message.dig(:info, :role) == "assistant"
386
+ end
387
+ return unless last_assistant
388
+
389
+ polled = Opencode::ResponseParser.extract_interleaved_parts(last_assistant)
390
+ reply.sync_recovered_parts(polled) if polled.any?
391
+ rescue Opencode::Error
392
+ # Stream's result is still complete; the merge was a polish, not a
393
+ # requirement.
394
+ end
395
+
396
+ # Healthy wait: opencode is suspended on a question/permission deferred
397
+ # and heartbeats are keeping the connection alive. Reset the deadline
398
+ # to "from now" so the full stuck-stream protection is restored once
399
+ # the prompt resolves. Otherwise apply the normal deadline check.
400
+ def check_deadline_or_suspend(now, deadline, timeout, reply)
401
+ return now + timeout if reply&.prompt_blocked?
402
+ raise TimeoutError, "SSE stream timed out after #{timeout}s" if now > deadline
403
+
404
+ deadline
405
+ end
406
+
407
+ def prompt_payload(text, parts:, model:, agent:, system:, message_id:, no_reply:, tools:, format:, variant:)
408
+ message_parts = parts || [ { type: "text", text: text } ]
409
+ {
410
+ messageID: message_id,
411
+ parts: message_parts,
412
+ model: format_model(model),
413
+ agent: agent,
414
+ noReply: no_reply,
415
+ tools: tools,
416
+ format: format,
417
+ system: system,
418
+ variant: variant
419
+ }.compact
420
+ end
421
+
422
+ def format_model(model)
423
+ return nil unless model
424
+ return model if model.is_a?(Hash)
425
+
426
+ provider, model_id = model.split("/", 2)
427
+ { providerID: provider, modelID: model_id }
428
+ end
429
+
430
+ def post(path, body)
431
+ uri = build_uri(path)
432
+ request = Net::HTTP::Post.new(uri)
433
+ request.body = body.to_json
434
+ execute(request)
435
+ end
436
+
437
+ def build_uri(path, scoped: true)
438
+ uri = @uri.dup
439
+ uri.path = path
440
+
441
+ if scoped
442
+ query = URI.decode_www_form(uri.query.to_s)
443
+ query << [ "directory", @directory ] if @directory.present?
444
+ query << [ "workspace", @workspace ] if @workspace.present?
445
+ uri.query = query.any? ? URI.encode_www_form(query) : nil
446
+ end
447
+
448
+ uri
449
+ end
450
+
451
+ def add_auth_header(request)
452
+ request["Content-Type"] = "application/json"
453
+ if @password.present?
454
+ request["Authorization"] = "Basic #{Base64.strict_encode64("opencode:#{@password}")}"
455
+ end
456
+ end
457
+
458
+ def execute(request)
459
+ add_auth_header(request)
460
+
461
+ response = nil
462
+ result = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do
463
+ response = http_client.request(request)
464
+ handle_response(response)
465
+ end
466
+
467
+ result
468
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
469
+ raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}"
470
+ rescue Errno::ECONNREFUSED, SocketError => e
471
+ raise ConnectionError, "OpenCode unreachable: #{e.message}"
472
+ end
473
+
474
+ def http_client
475
+ @http ||= Net::HTTP.new(@uri.host, @uri.port).tap do |http|
476
+ http.use_ssl = @uri.scheme == "https"
477
+ http.open_timeout = 10
478
+ http.read_timeout = @timeout
479
+ http.write_timeout = 30
480
+ end
481
+ end
482
+
483
+ def parse_sse_event(raw, session_id)
484
+ data_line = raw.lines.find { |l| l.start_with?("data: ") }
485
+ return nil unless data_line
486
+
487
+ json = JSON.parse(data_line.sub("data: ", "").strip, symbolize_names: true)
488
+
489
+ event_session = json.dig(:properties, :sessionID) ||
490
+ json.dig(:properties, :info, :sessionID) ||
491
+ json.dig(:properties, :part, :sessionID)
492
+
493
+ return json if json[:type] == "server.heartbeat"
494
+ return json if json[:type] == "server.connected"
495
+ return nil unless event_session == session_id
496
+
497
+ json
498
+ rescue JSON::ParserError
499
+ nil
500
+ end
501
+
502
+ def handle_response(response)
503
+ return {} if response.code.to_i == 204
504
+
505
+ body = if response.body.present?
506
+ JSON.parse(response.body, symbolize_names: true)
507
+ else
508
+ {}
509
+ end
510
+
511
+ case response.code.to_i
512
+ when 200..299 then body
513
+ when 400 then raise BadRequestError.new(error_message(body, "Bad request"), response: body)
514
+ when 404 then raise SessionNotFoundError.new(error_message(body, "Session not found"), response: body)
515
+ when 500..599 then raise ServerError.new(error_message(body, "Server error"), response: body)
516
+ else raise Error.new("Unexpected response: #{response.code}", response: body)
517
+ end
518
+ rescue JSON::ParserError
519
+ raise ServerError.new("Invalid JSON from OpenCode (HTTP #{response.code}): #{response.body&.truncate(200)}")
520
+ end
521
+
522
+ # OpenCode HTTP error bodies use a wrapped shape: { name:, data: { message:, kind?: } }.
523
+ # v1.14.51 stopped exposing internal defect details from the HTTP API, so
524
+ # `body[:message]` is no longer populated for errors — only `body[:data][:message]`.
525
+ # We read both to keep older mock servers working in tests.
526
+ def error_message(body, fallback)
527
+ body.dig(:data, :message) || body[:message] || fallback
528
+ end
529
+ end
530
+
531
+ # Internal Reply observer that bridges Reply's multi-callback protocol
532
+ # to a single user-supplied block for Client#stream. Each part-level
533
+ # callback (part_added, part_changed, part_finalized, tool_progressed)
534
+ # forwards the current part to the user's block.
535
+ #
536
+ # Non-part-level callbacks (step_finished, session_*, message_updated,
537
+ # todos_changed, question_*, permission_*) are intentionally NOT
538
+ # forwarded — they're either telemetry the gem owns internally, or
539
+ # interactive-protocol concerns that callers route through
540
+ # #stream_events directly when they need them.
541
+ class StreamBlockObserver
542
+ include Opencode::ReplyObserver
543
+
544
+ def initialize(&block)
545
+ @block = block
546
+ end
547
+
548
+ def part_added(part:, **)
549
+ @block.call(part)
550
+ end
551
+
552
+ def part_changed(part:, **)
553
+ @block.call(part)
554
+ end
555
+
556
+ def part_finalized(part:, **)
557
+ @block.call(part)
558
+ end
559
+
560
+ def tool_progressed(part:, **)
561
+ @block.call(part)
562
+ end
563
+ end
564
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencode
4
+ class Error < StandardError
5
+ attr_reader :response
6
+
7
+ def initialize(message = nil, response: nil)
8
+ @response = response
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ class ConnectionError < Error; end
14
+ class TimeoutError < Error; end
15
+ class SessionNotFoundError < Error; end
16
+ class StaleSessionError < Error; end
17
+ # Raised by stream_events when meaningful (non-`server.*`) events stop
18
+ # arriving for longer than the caller's `idle_stream_timeout` window,
19
+ # even though the SSE socket itself is still alive (heartbeats are
20
+ # still flowing). Distinct from StaleSessionError, which fires when
21
+ # the session never produced any events in the first place. This one
22
+ # fires when the session WAS producing events and then went silent —
23
+ # the classic "OpenAI stream wedged mid-turn while the SSE keep-
24
+ # alive ticks on" failure mode.
25
+ class IdleStreamError < Error; end
26
+ class ServerError < Error; end
27
+ class BadRequestError < Error; end
28
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencode
4
+ # Pluggable instrumentation adapter. opencode-ruby ships zero
5
+ # dependencies on Rails or any specific instrumentation library. Users
6
+ # plug in their own emitter:
7
+ #
8
+ # # ActiveSupport::Notifications (Rails apps):
9
+ # Opencode::Instrumentation.adapter = ->(name, payload, &block) {
10
+ # ActiveSupport::Notifications.instrument(name, payload, &block)
11
+ # }
12
+ #
13
+ # # stdout (debugging, non-Rails scripts):
14
+ # Opencode::Instrumentation.adapter = ->(name, payload, &block) {
15
+ # puts "[#{name}] #{payload.inspect}"
16
+ # block.call
17
+ # }
18
+ #
19
+ # When no adapter is set (default), instrumentation is a no-op pass-
20
+ # through that yields the block and returns its value. The Client emits
21
+ # events for HTTP requests, SSE stream lifecycle, and recovery paths.
22
+ #
23
+ # Event names the Client emits:
24
+ #
25
+ # - opencode.request — every HTTP request to OpenCode server
26
+ #
27
+ # If you wire a real adapter, the payload hash carries `:method` and
28
+ # `:path` for opencode.request. Other events may add fields in future
29
+ # versions; treat the payload as forward-compatible.
30
+ #
31
+ # Two emission shapes:
32
+ #
33
+ # .instrument(name, payload) { ... } — wrap a block; the duration
34
+ # of the block becomes part
35
+ # of the event (when the
36
+ # adapter is ActiveSupport::
37
+ # Notifications-shaped).
38
+ #
39
+ # .notify(name, payload) — fire-and-forget; no block,
40
+ # no duration. Use for
41
+ # point-in-time observations
42
+ # (e.g. "this artifact was
43
+ # dropped").
44
+ module Instrumentation
45
+ class << self
46
+ attr_accessor :adapter
47
+ end
48
+
49
+ # Yields the block, optionally routed through the adapter if one is
50
+ # set. Always returns the block's return value (so call sites can
51
+ # wrap their work transparently).
52
+ def self.instrument(name, payload = {})
53
+ return yield unless adapter
54
+
55
+ adapter.call(name, payload) { yield }
56
+ end
57
+
58
+ # Fire-and-forget event. No block, no return value (the adapter's
59
+ # return is ignored). Use for point-in-time observations where
60
+ # duration doesn't apply — apply_patch.artifacts_dropped,
61
+ # session.recreated, etc.
62
+ #
63
+ # Implementation: invokes the same adapter as #instrument but with
64
+ # an empty block. Hosts that adapt to ActiveSupport::Notifications
65
+ # will see a zero-duration event; hosts that adapt to a structured-
66
+ # event API (Rails.event.notify, OpenTelemetry span events) can
67
+ # detect the empty-block convention if they need to. Most hosts
68
+ # don't need to care.
69
+ def self.notify(name, payload = {})
70
+ return unless adapter
71
+
72
+ adapter.call(name, payload) { }
73
+ nil
74
+ end
75
+ end
76
+ end