diff --git a/CHANGELOG.md b/CHANGELOG.md index 888882f..09083b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.0.1.alpha2 — 2026-05-20 + +### Added + +- `Opencode::Instrumentation.notify(name, payload)` — fire-and-forget + emission for point-in-time events that don't need duration measurement + (apply_patch.artifacts_dropped, session.recreated, etc.). Adapter + receives an empty block so AS::Notifications-shaped sinks see a + zero-duration event. Complements the existing block-form + `.instrument(name, payload) { ... }`. + +### Why + +The block-form `.instrument(name, payload) { }` with an empty block was +awkward at fire-and-forget call sites in opencode-rails. Two named +verbs (`instrument` for wrap-a-block, `notify` for fire-and-forget) +match the host-side mental model and read better at the call site. + ## 0.0.1.alpha1 — Unreleased First public alpha. HTTP + SSE client for OpenCode REST API. diff --git a/lib/opencode/instrumentation.rb b/lib/opencode/instrumentation.rb index 7b36db5..304f133 100644 --- a/lib/opencode/instrumentation.rb +++ b/lib/opencode/instrumentation.rb @@ -27,6 +27,20 @@ module Opencode # If you wire a real adapter, the payload hash carries `:method` and # `:path` for opencode.request. Other events may add fields in future # versions; treat the payload as forward-compatible. + # + # Two emission shapes: + # + # .instrument(name, payload) { ... } — wrap a block; the duration + # of the block becomes part + # of the event (when the + # adapter is ActiveSupport:: + # Notifications-shaped). + # + # .notify(name, payload) — fire-and-forget; no block, + # no duration. Use for + # point-in-time observations + # (e.g. "this artifact was + # dropped"). module Instrumentation class << self attr_accessor :adapter @@ -40,5 +54,23 @@ module Opencode adapter.call(name, payload) { yield } end + + # Fire-and-forget event. No block, no return value (the adapter's + # return is ignored). Use for point-in-time observations where + # duration doesn't apply — apply_patch.artifacts_dropped, + # session.recreated, etc. + # + # Implementation: invokes the same adapter as #instrument but with + # an empty block. Hosts that adapt to ActiveSupport::Notifications + # will see a zero-duration event; hosts that adapt to a structured- + # event API (Rails.event.notify, OpenTelemetry span events) can + # detect the empty-block convention if they need to. Most hosts + # don't need to care. + def self.notify(name, payload = {}) + return unless adapter + + adapter.call(name, payload) { } + nil + end end end diff --git a/lib/opencode/version.rb b/lib/opencode/version.rb index c09bd31..538607c 100644 --- a/lib/opencode/version.rb +++ b/lib/opencode/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Opencode - VERSION = "0.0.1.alpha1" + VERSION = "0.0.1.alpha2" end diff --git a/opencode-ruby.gemspec b/opencode-ruby.gemspec index 0560213..8eea3c4 100644 --- a/opencode-ruby.gemspec +++ b/opencode-ruby.gemspec @@ -16,12 +16,13 @@ Gem::Specification.new do |spec| OpenAPI) — pick this one if you want a small Ruby-idiomatic surface; pick opencode_client if you want every endpoint with generated types. DESC - spec.homepage = "https://gitea.krishnan.ca/ajaynomics/opencode-ruby" + spec.homepage = "https://github.com/ajaynomics/opencode-ruby" spec.license = "MIT" spec.required_ruby_version = ">= 3.2.0" + spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "#{spec.homepage}/src/branch/main/CHANGELOG.md" + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues" spec.files = Dir.glob("lib/**/*.rb") + diff --git a/test/opencode/smoke_test.rb b/test/opencode/smoke_test.rb index 562ce6d..a1e4109 100644 --- a/test/opencode/smoke_test.rb +++ b/test/opencode/smoke_test.rb @@ -166,4 +166,43 @@ class SmokeTest < Minitest::Test Opencode::Instrumentation.adapter = nil assert_equal 42, Opencode::Instrumentation.instrument("x") { 42 } end + + def test_Instrumentation_notify_no_op_without_adapter + Opencode::Instrumentation.adapter = nil + # Must not raise; must return nil. + assert_nil Opencode::Instrumentation.notify("x", foo: 1) + end + + def test_Instrumentation_notify_forwards_to_adapter_fire_and_forget + events = [] + Opencode::Instrumentation.adapter = ->(name, payload, &block) { + # block_given? is misleading inside a lambda — check the captured + # &block instead. AS::Notifications-shaped adapters always + # expect a block (it's what marks "event finished"). + events << [ name, payload, !block.nil? ] + block.call if block + :adapter_return_ignored + } + + result = Opencode::Instrumentation.notify("opencode.session.recreated", session_id: "ses_1") + + # notify is fire-and-forget — it returns nil, NOT the adapter's + # return value (that's what .instrument does). + assert_nil result + assert_equal 1, events.size + name, payload, had_block = events.first + assert_equal "opencode.session.recreated", name + assert_equal({ session_id: "ses_1" }, payload) + assert had_block, + "notify must still pass an empty block — AS::Notifications-shaped " \ + "adapters always expect one" + end + + def test_Instrumentation_notify_does_not_require_block + Opencode::Instrumentation.adapter = ->(_name, _payload, &_block) { } + # Call site has no block — that's the whole point of notify. + Opencode::Instrumentation.notify("opencode.test", k: "v") + # If we got here without raising, the API is fire-and-forget as designed. + assert true + end end