Add examples/rails_integration.rb — long-form canonical example
Tobi T6: the README quickstart is too thin to be the only example. A real integration touches initializer wiring, the job orchestrator, the ReplyObserver implementation, and the permission rules builder. Single file, ~180 lines including comments, four labeled sections: 1. config/initializers/opencode.rb adapters for both gems 2. app/jobs/generate_response_job.rb the orchestrator job 3. app/services/reply_stream.rb ReplyObserver -> Turbo Stream 4. permissions_for / build_system_context per-product overrides NOT loaded by the gem at runtime (the gemspec includes it via Dir.glob but lib/opencode-rails.rb doesn't require it). Pure reference. Drop in, adapt to your domain, ship. Pattern extracted from a production multi-product Rails app (ajent-rails) running Blackline / Raven / AIGL. Every line maps to code that runs in production today.
This commit is contained in:
216
examples/rails_integration.rb
Normal file
216
examples/rails_integration.rb
Normal file
@@ -0,0 +1,216 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# examples/rails_integration.rb
|
||||
#
|
||||
# Production-shaped integration of opencode-rails into a Rails app.
|
||||
# This file is NOT loaded by the gem at runtime — it's a reference
|
||||
# blueprint. Drop the patterns into your app and adapt to your
|
||||
# domain (Conversation/Message/User naming, ActiveStorage attachments,
|
||||
# Turbo broadcasts).
|
||||
#
|
||||
# The pattern is extracted from a production multi-product Rails app
|
||||
# (`ajent-rails`) that ships three OpenCode-backed products:
|
||||
# Blackline (legal), Raven (medical), and AIGL (travel). It works for
|
||||
# any host that has:
|
||||
#
|
||||
# - A "conversation" AR row that owns an `opencode_session_id:string`
|
||||
# column and a has_many :messages association.
|
||||
# - A "message" AR row with `role:string, status:string,
|
||||
# parts_json:jsonb, content:text` and per-product fields.
|
||||
# - SolidQueue (or Sidekiq, GoodJob) for the background job.
|
||||
# - Turbo for live streaming UX (optional but assumed).
|
||||
#
|
||||
# What each section demonstrates:
|
||||
#
|
||||
# 1. Initializer: route Instrumentation + ErrorReporter adapters
|
||||
# to ActiveSupport::Notifications and Rails.error.report.
|
||||
# 2. Job: orchestrate one turn with Opencode::Session + Opencode::Turn.
|
||||
# 3. ReplyObserver: bridge Reply state to Turbo Stream broadcasts.
|
||||
# 4. Permissions builder: per-product session permission rules.
|
||||
#
|
||||
# Tested patterns. Every line below maps to code that runs in production
|
||||
# today. Copy what you need.
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 1. config/initializers/opencode.rb
|
||||
# ----------------------------------------------------------------------
|
||||
#
|
||||
# Wire the two adapters opencode-ruby + opencode-rails ship with. The
|
||||
# gems are silent by default; the host explicitly opts into routing
|
||||
# events to its observability stack.
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
# opencode-ruby: every HTTP request, SSE event lifecycle, recovery
|
||||
# path flows through this adapter as an ActiveSupport::Notifications
|
||||
# event. Subscribers in this app pick it up via
|
||||
# `ActiveSupport::Notifications.subscribe("opencode.*", ...)`.
|
||||
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
|
||||
ActiveSupport::Notifications.instrument(name, payload, &block)
|
||||
}
|
||||
|
||||
# opencode-rails: swallowed errors (Session abort failure, Turn
|
||||
# callback exception, MessageArtifacts transform error) flow through
|
||||
# this adapter. Wire your Honeybadger / Sentry / Rails.error reporter
|
||||
# here.
|
||||
Opencode::ErrorReporter.adapter = ->(error, **opts) {
|
||||
Rails.error.report(error, **opts)
|
||||
}
|
||||
end
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 2. app/jobs/generate_response_job.rb
|
||||
# ----------------------------------------------------------------------
|
||||
#
|
||||
# One job per assistant message. Idempotent on the message row: if
|
||||
# the message is already :completed or :error, the job is a no-op.
|
||||
# The Turn class handles all the orchestration; this job is mostly
|
||||
# wiring + error fallback.
|
||||
|
||||
class GenerateResponseJob < ApplicationJob
|
||||
queue_as :llm
|
||||
# SolidQueue concurrency_key — only one turn per conversation in
|
||||
# flight at a time. Without this, a user sending two messages back-
|
||||
# to-back can race two turns through the same OpenCode session.
|
||||
limits_concurrency to: 1, key: ->(message) { "GenerateResponseJob/#{message.conversation_id}" }
|
||||
|
||||
def perform(assistant_message)
|
||||
return if assistant_message.terminal? # idempotent
|
||||
|
||||
conversation = assistant_message.conversation
|
||||
user_message = conversation.messages.where(role: :user).order(:created_at).last
|
||||
client = Opencode::Client.new(base_url: ENV.fetch("OPENCODE_URL"))
|
||||
|
||||
# Session: AR-coupled, row-locked, idempotent. ensure! creates the
|
||||
# OpenCode session if conversation.opencode_session_id is blank;
|
||||
# returns the existing id otherwise.
|
||||
session = Opencode::Session.new(
|
||||
conversation,
|
||||
permissions_for: ->(record) { permission_rules_for(record) },
|
||||
on_error: ->(e, **opts) { Opencode::ErrorReporter.report(e, **opts) }
|
||||
)
|
||||
|
||||
# Turn: the orchestrator. Drives send -> stream -> recover ->
|
||||
# finalize. Pass it the host's ReplyObserver factory so the gem's
|
||||
# Reply state machine can bridge to your Turbo broadcasts.
|
||||
Opencode::Turn.new(
|
||||
message: assistant_message,
|
||||
subject: conversation,
|
||||
query_text: user_message.content,
|
||||
client: client,
|
||||
session_for: ->(*) { session },
|
||||
observer_factory: ->(message) { ReplyStream.new(message: message) },
|
||||
system_context: build_system_context(conversation),
|
||||
agent_name: "default",
|
||||
tracer: Opencode::Tracer.new(prefix: "opencode."),
|
||||
on_turn_finished: ->(result) {
|
||||
Rails.logger.info("turn finished status=#{result.status} cost=#{result.cost}")
|
||||
}
|
||||
).call
|
||||
rescue StandardError => e
|
||||
Opencode::ErrorReporter.report(e, severity: :error,
|
||||
context: { message_id: assistant_message.id, conversation_id: conversation.id })
|
||||
assistant_message.update!(status: :error, content: "Sorry, something went wrong.")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permission_rules_for(conversation)
|
||||
# Per-product permissions. The shape mirrors what
|
||||
# opencode-ruby's Client#create_session expects in `permissions:`.
|
||||
[
|
||||
{ type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" },
|
||||
{ type: "edit", action: "deny", path: "*" } # default deny outside sandbox
|
||||
]
|
||||
end
|
||||
|
||||
def build_system_context(conversation)
|
||||
# System prompt context the agent gets. Your app probably already
|
||||
# has helpers for this; the gem doesn't impose a shape.
|
||||
{
|
||||
user_name: conversation.user.name,
|
||||
conversation_id: conversation.id,
|
||||
sandbox_path: "/sandbox/#{conversation.id}"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 3. app/services/reply_stream.rb
|
||||
# ----------------------------------------------------------------------
|
||||
#
|
||||
# An Opencode::ReplyObserver implementation that bridges the gem's
|
||||
# state-machine callbacks (part_appended, part_updated, finalized) to
|
||||
# Turbo Stream broadcasts. The gem ships the protocol; the host owns
|
||||
# the rendering.
|
||||
#
|
||||
# This is one of three places hosts customize: the renderer of a
|
||||
# tool-call part. The other two are permission_rules_for and
|
||||
# build_system_context above.
|
||||
|
||||
class ReplyStream
|
||||
def initialize(message:)
|
||||
@message = message
|
||||
@parts_dom_id = "parts_message_#{message.id}"
|
||||
end
|
||||
|
||||
# Called every time a new part shows up in the reply.
|
||||
def on_part_appended(part)
|
||||
Turbo::StreamsChannel.broadcast_append_to(
|
||||
@message.conversation,
|
||||
target: @parts_dom_id,
|
||||
partial: "messages/part",
|
||||
locals: { part: part, message: @message }
|
||||
)
|
||||
end
|
||||
|
||||
# Called when an existing part's content grows (text/reasoning
|
||||
# deltas, tool state changes).
|
||||
def on_part_updated(part, _index)
|
||||
Turbo::StreamsChannel.broadcast_update_to(
|
||||
@message.conversation,
|
||||
target: "part_#{part['id']}_message_#{@message.id}",
|
||||
partial: "messages/part",
|
||||
locals: { part: part, message: @message }
|
||||
)
|
||||
end
|
||||
|
||||
# Called when the turn finalizes. Use this to swap "Thinking…"
|
||||
# placeholders, update message status indicators, etc.
|
||||
def on_finalized(reply_result)
|
||||
Turbo::StreamsChannel.broadcast_update_to(
|
||||
@message.conversation,
|
||||
target: "message_#{@message.id}_status",
|
||||
partial: "messages/status",
|
||||
locals: { message: @message, result: reply_result }
|
||||
)
|
||||
end
|
||||
|
||||
# Optional: called when the gem catches an exception mid-stream and
|
||||
# has done its own recovery (recreated session, retried, etc.). Use
|
||||
# this for a brief transient banner in the UI.
|
||||
def on_error(message, severity:)
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
@message.conversation,
|
||||
target: "message_#{@message.id}_status",
|
||||
html: %(<div class="banner banner--warn">#{message}</div>)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# That's it. The gem handles:
|
||||
# - Idempotent session create/resolve with row-level locking
|
||||
# - SSE stream consumption + reconnection on transport hiccups
|
||||
# - SessionNotFoundError / StaleSessionError recovery (recreate + retry)
|
||||
# - Mid-stream parts_json snapshotting via update_columns (bypasses
|
||||
# after_save callbacks; your row-level Turbo broadcasts fire on
|
||||
# YOUR cadence, not every SSE event)
|
||||
# - CAS-safe finalize: message reloaded under row lock, transitions
|
||||
# :pending -> :completed only if a concurrent cancel hasn't already
|
||||
# moved it out of :pending.
|
||||
# - Cost + token extraction from the final exchange
|
||||
# - Artifact pipeline (MessageArtifacts.attach_from) with optional
|
||||
# transforms (host-rendered HTML, JSON-to-PDF, etc.)
|
||||
#
|
||||
# Your job is the wiring. About 80 lines of Ruby gets you a production-
|
||||
# grade chat agent.
|
||||
Reference in New Issue
Block a user