Initial public release v0.0.1.alpha2
opencode-rails — production-grade Rails integration for OpenCode. Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle (idempotent ensure!/recreate!/abort! with row-level locks), a Turn orchestrator driving the Reply state machine and recovering from session-not-found, an artifact pipeline backed by ActiveStorage, sandbox seeding, and tool-display value objects for Turbo Stream broadcasts. Drop into any Rails 7.1+ app that wants production-grade OpenCode streaming without rolling boilerplate. What this version ships: - Opencode::Session (AR-coupled lifecycle, row-level locks) - Opencode::Turn (Reply state machine, session-not-found recovery) - Opencode::Exchange (one turn = one request/response unit) - Opencode::Impostor (deterministic mock for tests) - Opencode::Sandbox / SandboxFile (per-session FS scratch space) - Opencode::Transform (host-rendered artifact pipeline) - Opencode::Artifact / MessageArtifacts (ActiveStorage-backed) - Opencode::UploadedFilesPrompt (system-prompt builder) - Opencode::ToolDisplay (Turbo Stream value objects) - Opencode::ErrorReporter (pluggable adapter — Honeybadger/Sentry/etc.) - examples/rails_integration.rb — canonical wiring blueprint 53 smoke tests. CI on Ruby 3.2/3.3/3.4. Ruby >= 3.2. Runtime deps: opencode-ruby = 0.0.1.alpha2, activerecord/activestorage/activesupport >= 7.1, < 9.0. See CHANGELOG.md for the alpha1 -> alpha2 delta.
This commit is contained in:
79
lib/opencode/exchange.rb
Normal file
79
lib/opencode/exchange.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Opencode
|
||||
# The OpenCode messages produced by a single turn (the array returned
|
||||
# by GET /session/:id/message and consumed by Opencode::Turn's
|
||||
# recovery + finalization paths).
|
||||
#
|
||||
# First-class noun rather than a bare array because:
|
||||
#
|
||||
# - It owns the "give me the tool-produced artifacts" question, so
|
||||
# callers don't reach into ResponseParser for that. The parser is
|
||||
# about wire-shape extraction; the Exchange is about the domain
|
||||
# concept of "what files came out of this turn."
|
||||
#
|
||||
# - It owns the "opencode.apply_patch.artifacts_dropped" event
|
||||
# emission, keeping ResponseParser a pure module (no instrumentation
|
||||
# side effects). Pure functions stay pure. The event flows through
|
||||
# Opencode::Instrumentation, so hosts wire AS::Notifications /
|
||||
# Rails.event / OpenTelemetry / etc. via the adapter.
|
||||
class Exchange
|
||||
def initialize(messages)
|
||||
@messages = Array(messages)
|
||||
end
|
||||
|
||||
# Returns Opencode::Artifact values for every file produced by a
|
||||
# tool call in this exchange (currently the `write` tool; apply_patch
|
||||
# is acknowledged-but-empty in v1.15+, see ResponseParser).
|
||||
#
|
||||
# `exclude:` filters by destination filename — used by the substrate
|
||||
# to keep tool-extracted Artifacts from racing per-message transforms
|
||||
# that own the same filenames.
|
||||
def tool_artifacts(exclude: [])
|
||||
excluded = Set.new(exclude)
|
||||
raw = Opencode::ResponseParser.extract_artifacts_from_messages(@messages)
|
||||
notify_drops(raw)
|
||||
|
||||
raw.filter_map do |file_data|
|
||||
next if excluded.include?(file_data[:filename])
|
||||
|
||||
Artifact.new(
|
||||
filename: file_data[:filename],
|
||||
content: file_data[:content],
|
||||
content_type: file_data[:content_type]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# ResponseParser annotates dropped apply_patch parts on the messages
|
||||
# it processes (since v1.15+ wire shape carries no inline post-write
|
||||
# content). The notify lives here, not in the parser, so the parser
|
||||
# stays a pure function. Operators see one event per assistant
|
||||
# message that contained an apply_patch tool call.
|
||||
def notify_drops(_)
|
||||
@messages.each do |message|
|
||||
next unless message.dig(:info, :role) == "assistant"
|
||||
|
||||
parts = message[:parts] || []
|
||||
parts.each do |part|
|
||||
next unless part[:type] == "tool" && part[:tool] == "apply_patch"
|
||||
next unless part.dig(:state, :status) == "completed"
|
||||
|
||||
file_entries = part.dig(:state, :metadata, :files) || []
|
||||
eligible = file_entries.reject { |e| e[:type] == "delete" }
|
||||
next if eligible.empty?
|
||||
|
||||
Opencode::Instrumentation.notify("opencode.apply_patch.artifacts_dropped",
|
||||
file_count: eligible.size,
|
||||
relative_paths: eligible.filter_map { |e| e[:relativePath] }.first(5),
|
||||
message_id: part[:messageID],
|
||||
session_id: part[:sessionID],
|
||||
reason: "apply_patch v1.15+ metadata does not include post-write file content; " \
|
||||
"extraction requires sandbox-read which is not yet wired into ResponseParser")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user