diff --git a/examples/rails_integration.rb b/examples/rails_integration.rb new file mode 100644 index 0000000..8efa003 --- /dev/null +++ b/examples/rails_integration.rb @@ -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: %(
) + ) + 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.