From 65ee701caec282e73b5609537a2d707b14003530 Mon Sep 17 00:00:00 2001 From: Ajay Krishnan Date: Wed, 20 May 2026 05:17:14 -0700 Subject: [PATCH] Add README + CHANGELOG for v0.0.1.alpha1 README (~140 LOC): - Why this gem exists (4 production needs opencode-ruby alone doesn't cover) - Install + quickstart (showing GenerateResponseJob wiring Session + Turn) - 12-constant API table with one-line role descriptions - Instrumentation + error-reporting adapter setup snippets - Position against opencode-ruby and Eric Guo's opencode_client CHANGELOG documents what shipped, runtime deps, and known limitations (apply_patch artifact drop, gem-side behavioral tests TBD, no Rails generator yet). LICENSE was auto-generated by Gitea (MIT, 2026 ajaynomics) and is unchanged. --- CHANGELOG.md | 34 ++++++++++++++ README.md | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0d904c8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 0.0.1.alpha1 — 2026-05-20 + +Initial public alpha. Extracted from a production multi-product Rails app (`ajent-rails`) where these objects shipped under `lib/opencode/rails/` before being carved out into a standalone gem. + +**Includes:** + +- `Opencode::Session` — AR-coupled, row-level-locked session lifecycle (`ensure!`, `recreate!`, `abort!`) +- `Opencode::Turn` — orchestrator covering send → stream → recover → finalize, with CAS-safe message terminal-state transitions +- `Opencode::Exchange` — domain object over a turn's message array; emits `opencode.apply_patch.artifacts_dropped` when post-write file content is unavailable +- `Opencode::Artifact` — value-object (filename + content + content_type + trust metadata), idempotent attach +- `Opencode::MessageArtifacts` — ActiveStorage-aware artifact attachment pipeline with transform support +- `Opencode::Sandbox` — disk-backed sandbox reader, returns `Artifact` list +- `Opencode::SandboxFile` — single-file value object (pathname → bytes/content-type) +- `Opencode::Transform` — base class for content-rewriting transforms +- `Opencode::Impostor` — ActiveStorage download/upload round-trip helper +- `Opencode::UploadedFilesPrompt` — user-prompt prefix builder listing uploaded files, sandbox-path inverted (injection-based, no `Opencode::Permissions` reference) +- `Opencode::ToolDisplay` — view-model for tool-call hashes (Turbo Stream-friendly) +- `Opencode::ErrorReporter` — pluggable adapter mirroring the `Opencode::Instrumentation` pattern + +**Runtime dependencies:** + +- `opencode-ruby ~> 0.0.1.alpha1` (wire client + Reply state machine) +- `activerecord >= 7.1, < 9.0` +- `activestorage >= 7.1, < 9.0` +- `activesupport >= 7.1, < 9.0` + +**Known limitations (alpha):** + +- Apply-patch tool's post-write file content is not extracted (wire-format limitation in OpenCode v1.15+); affected files surface via the `opencode.apply_patch.artifacts_dropped` instrumentation event. Future work: optional sandbox-read fallback path. +- Smoke tests only inside the gem (14 tests). Behavioral coverage lives in the host app that originally produced this code (`ajent-rails`'s `test/lib/opencode/rails/`). Standalone gem-side test suite using Combustion is open work. +- No generator (`rails g opencode:install`) yet. +- No Rails Engine integration — `require "opencode-rails"` is sufficient. diff --git a/README.md b/README.md index c13a407..bfae076 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,123 @@ # opencode-rails -Production-grade Rails integration for OpenCode. ActiveRecord-aware session lifecycle, Turbo-friendly streaming, mid-stream snapshot pattern. \ No newline at end of file +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://gitea.krishnan.ca/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 + +```ruby +# Gemfile +gem "opencode-ruby" # wire client + Reply state machine +gem "opencode-rails" # AR-coupled session/turn/artifact stack +``` + +```bash +bundle install +``` + +Runtime deps: `activerecord`, `activestorage`, `activesupport` (>= 7.1). Depends on `opencode-ruby` for the underlying HTTP/SSE primitives. + +## 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/jobs/generate_response_job.rb +class GenerateResponseJob < ApplicationJob + def perform(assistant_message) + conversation = assistant_message.conversation + user_message = conversation.messages.where(role: :user).last + client = Opencode::Client.new(base_url: ENV["OPENCODE_URL"]) + + 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: session, + on_turn_finished: ->(result) { + # result.status #=> :completed | :error | :cancelled + # result.message #=> the AR row (reloaded) + # result.duration_ms + } + ).call + end + + private + + def permission_rules_for(conversation) + [ + { type: "edit", action: "allow", path: "data/sandbox/#{conversation.id}/" } + ] + end +end +``` + +The host's record (here `conversation`) must respond to `#title`, `#opencode_session_id`, `#opencode_session_id=`, `#with_lock(&block)`, `#update!`, `#reload`, `#id`. The host's message record (here `assistant_message`) must respond to `#error!(content)`, `#update_columns(...)`, `#with_lock(&block)`, `#reload`, `#pending?`. + +## 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 gem emits events through `Opencode::Instrumentation.instrument(name, payload, &blk)` and reports swallowed errors through `Opencode::ErrorReporter.report(error, **opts)`. Both are no-ops by default. Wire your host's emitter / reporter in an initializer (see Quickstart above). + +Events emitted (non-exhaustive): + +- `opencode.turn.started`, `opencode.turn.finished` +- `opencode.stream.completed`, `opencode.stream.interrupted` +- `opencode.session.created`, `opencode.session.recreated` +- `opencode.apply_patch.artifacts_dropped` +- `opencode.response.upstream_error` + +Subscribe via `ActiveSupport::Notifications.subscribe("opencode.*")` once you've wired the adapter. + +## 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).