Files
opencode-rails/README.md

237 lines
10 KiB
Markdown

# opencode-rails
Production-grade [OpenCode](https://opencode.ai) integration for Rails apps. Layers an ActiveRecord-aware session lifecycle, a turn orchestrator, an artifact pipeline, and a sandbox model on top of the wire-level client in [`opencode-ruby`](https://github.com/ajaynomics/opencode-ruby).
> **Alpha software.** API will change before 1.0. Pin to a specific version.
## Why this gem exists
`opencode-ruby` gives you a clean Net::HTTP + SSE client. To turn it into something a Rails app can ship, you need:
1. **Session lifecycle** — idempotent create-or-resolve with row-level locking so concurrent jobs don't double-mint sessions; recreation for stale-session recovery; best-effort upstream abort on teardown.
2. **Turn orchestration** — drive `send_message_async`, stream events into `Opencode::Reply`, recover from `SessionNotFoundError` / `StaleSessionError` by recreating + retrying, fetch the final exchange for cost/artifact extraction, finalize the persisted message via CAS against `:pending`.
3. **Mid-stream snapshot**`update_columns` writes to bypass AR callbacks so app-level Turbo broadcasts fire on rate-limited intervals, not every SSE event.
4. **Artifact pipeline** — extract files written by the agent (via the `write` tool's metadata, or by reading from a sandbox path), attach them to the assistant message via ActiveStorage, transform host-rendered artifacts (flight results HTML, etc.) before persisting.
`opencode-rails` ships these as named, single-responsibility objects so you wire them up instead of re-implementing them.
## Install
After both alpha9 gems are confirmed on RubyGems, pin the lockstep tuple:
```ruby
# Gemfile
gem "opencode-ruby", "= 0.0.1.alpha9" # wire client + Reply state machine
gem "opencode-rails", "= 0.0.1.alpha9"
```
Until publication is verified, validate this candidate checkout against the
exact `opencode-ruby` source it was tested with:
```ruby
# Gemfile
gem "opencode-ruby",
git: "https://github.com/ajaynomics/opencode-ruby.git",
ref: "65a44ca1502926d533e6b4b6692779fa39740218"
gem "opencode-rails",
path: "../opencode-rails"
```
```bash
bundle install
```
Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1), and
`marcel`. Depends on `opencode-ruby` for the underlying HTTP/SSE primitives.
`UploadedFilesPrompt` anchors upload writes through `/proc/self/fd` or
`/dev/fd` to prevent sandbox-root path swaps. Hosts using upload copying must
provide one of those traversable descriptor filesystems; unsupported platforms
fail closed before writing.
During the alpha series both gems are pinned in lockstep. Version 0.0.1.alpha9
retains the subscribe-ready-before-prompt transport contract and reconnects an
accepted turn without posting its prompt again, while hardening SSE framing.
The unrepaired `opencode-ruby` 0.0.1.alpha8 package was yanked, and
`opencode-rails` alpha8 was never published.
`opencode-rails` 0.0.1.alpha9 is a release candidate and is not yet confirmed
published on RubyGems. The
`release.yml` workflow is prepared for RubyGems
trusted publishing, but the gem's trusted publisher must be registered and
verified for that workflow and its `release` environment. Until the registry
result is verified, pushing a `v*` tag does not guarantee publication. Trusted
publishing does not require a long-lived RubyGems API key.
## Quickstart
```ruby
# config/initializers/opencode.rb
Opencode::Instrumentation.adapter = ->(name, payload, &blk) {
ActiveSupport::Notifications.instrument(name, payload, &blk)
}
Opencode::ErrorReporter.adapter = ->(error, **opts) {
Rails.error.report(error, **opts) if Rails.respond_to?(:error)
}
```
```ruby
# app/services/noop_reply_observer.rb
#
# Turn requires an observer factory even when the app does not need live
# partial rendering. For a streaming UI, replace this with an observer that
# persists/broadcasts selected ReplyObserver callbacks (and throttle writes).
class NoopReplyObserver
include Opencode::ReplyObserver
def watch(reply)
reply.add_observer(self)
self
end
end
```
```ruby
# app/jobs/generate_response_job.rb
class GenerateResponseJob < ApplicationJob
def perform(assistant_message, user_message)
conversation = assistant_message.conversation
unless user_message.conversation_id == conversation.id
raise ArgumentError, "User and assistant messages must belong to the same conversation"
end
sandbox_directory = sandbox_directory_for(conversation)
client = Opencode::Client.new(
base_url: ENV.fetch("OPENCODE_URL"),
directory: sandbox_directory.to_s
)
session = Opencode::Session.new(
conversation,
permissions_for: ->(record) { permission_rules_for(record) },
on_error: ->(e, **opts) { Opencode::ErrorReporter.report(e, **opts) }
)
Opencode::Turn.new(
message: assistant_message,
subject: conversation,
query_text: user_message.content,
client: client,
session_for: session,
observer_factory: ->(_message) { NoopReplyObserver.new },
system_context: ->(record) { "You are assisting with #{record.title}." },
agent_name: ->(_record) { ENV.fetch("OPENCODE_AGENT", "build") },
tracer: ->(name, **payload) {
ActiveSupport::Notifications.instrument("assistant.#{name}", payload)
},
on_turn_finished: ->(result) {
# result.status #=> :completed | :cancelled | :error | :failed
# result.message #=> the AR row (reloaded)
# result.duration_ms
}
).call
end
private
def permission_rules_for(_conversation)
[
{ permission: "*", pattern: "*", action: "deny" }
]
end
def sandbox_directory_for(conversation)
identifier = conversation.id.to_s
unless identifier.match?(/\A[0-9A-Za-z_-]+\z/)
raise ArgumentError, "Conversation id is not safe for a directory name"
end
root = Pathname.new(ENV.fetch("OPENCODE_SANDBOX_ROOT")).expand_path
root.mkpath
root = root.realpath
if root.ascend.any? { |directory| directory.join(".git").exist? }
raise ArgumentError, "OPENCODE_SANDBOX_ROOT must be outside every Git worktree"
end
directory = root.join(identifier)
directory.mkpath
stat = directory.lstat
unless stat.directory? && !stat.symlink?
raise ArgumentError, "Conversation sandbox must be a real directory"
end
resolved = directory.realpath
unless resolved.to_s.start_with?("#{root}#{File::SEPARATOR}")
raise ArgumentError, "Conversation sandbox escapes its root"
end
resolved
end
end
```
`OPENCODE_SANDBOX_ROOT` must be outside every Git worktree and mounted at the
same absolute path in the OpenCode server. OpenCode treats an entire detected
worktree as internal, so a directory inside the application repository is not
an `external_directory` boundary. This quickstart intentionally grants no
filesystem tools: OpenCode permissions alone are not an OS sandbox. Add
product-specific allows only when the OpenCode process also has an independent
container or operating-system boundary around the conversation directory.
`Opencode::Session` applies permissions only when it creates a session. Before
deploying a directory or permission-policy change, recreate persisted sessions
with a client scoped to the new directory; an existing session ID does not
prove that the new policy was applied.
The host record (here `conversation`) must respond to `#title`,
`#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`,
`#update!`, `#reload`, and `#id`. The assistant message must respond to `#id`,
`#reload`, `#cancelled?`, `#finalize!(**attrs)`, `#update!(**attrs)`, and
`#error!(content)`. A non-no-op observer may impose additional record methods
for its own live snapshots.
`Opencode::Turn` is an internal, alpha-stage composition seam. Its keyword
constructor is intentionally explicit and may change before 1.0, so keep this
wiring in one host service/job and keep the gem source pinned exactly.
## What you get
| Constant | Role |
|---|---|
| `Opencode::Session` | Idempotent ensure!/recreate!/abort! with row-level locking |
| `Opencode::Turn` | End-to-end orchestrator: ensure session → send → stream → recover → finalize |
| `Opencode::Exchange` | Domain object over a turn's message array; owns artifact extraction |
| `Opencode::Artifact` | Value object: filename + bytes + content_type + trust metadata |
| `Opencode::MessageArtifacts` | Idempotent attachment pipeline (ActiveStorage-aware, transform-applying) |
| `Opencode::Sandbox` | Reads written files from disk, returns Artifact list |
| `Opencode::SandboxFile` | Single-file value: pathname → bytes/content-type/identity Artifact |
| `Opencode::Transform` | Base class for content-rewriting transforms (e.g. host-rendered HTML) |
| `Opencode::Impostor` | ActiveStorage download/upload helper that round-trips bytes |
| `Opencode::UploadedFilesPrompt` | Builds the user-prompt prefix listing uploaded files |
| `Opencode::ToolDisplay` | View-model converting tool-call hashes to Turbo-friendly props |
| `Opencode::ErrorReporter` | Pluggable adapter for routing swallowed errors |
Plus everything from `opencode-ruby`: `Client`, `Reply`, `ReplyObserver`, `Tracer`, `Prompts`, `ResponseParser`, `ToolPart`, `PartSource`, `Todo`, `Instrumentation`, the error hierarchy.
## Instrumentation + error reporting
The wire client emits `opencode.request` and artifact extraction emits
`opencode.apply_patch.artifacts_dropped` through `Opencode::Instrumentation`.
Turn events flow through the injected tracer; with the Quickstart's
`assistant.` prefix they include:
- `assistant.response.started`, `assistant.turn.finished`
- `assistant.stream.completed`, `assistant.stream.interrupted`
- `assistant.session.created`, `assistant.session.recreated_with_resend`
- `assistant.response.upstream_error`
Swallowed errors flow through `Opencode::ErrorReporter.report(error, **opts)`.
Instrumentation and error reporting are no-ops until the host wires adapters.
## Position
`opencode-rails` is for Rails apps that want production-grade OpenCode streaming without reimplementing the orchestration boilerplate. If you want only the wire client, use `opencode-ruby` directly. If you want every endpoint with generated types, use [`opencode_client`](https://rubygems.org/gems/opencode_client) (Eric Guo's auto-generated gem).
## License
MIT. See [LICENSE](LICENSE).