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.
64 lines
2.1 KiB
Ruby
64 lines
2.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Opencode
|
|
# A file the host wants to attach to an assistant message: filename,
|
|
# content bytes, MIME type, and an optional trust-metadata hash.
|
|
#
|
|
# Artifacts come from two places in the substrate:
|
|
#
|
|
# - Opencode::Exchange.tool_artifacts — content lives inside a tool
|
|
# call's input/metadata (write tool).
|
|
# - Opencode::SandboxFile#as_artifact — identity conversion of a
|
|
# sandbox-resident file (the default identity path).
|
|
#
|
|
# Transforms also return Artifacts — e.g. a host-rendered HTML
|
|
# artifact carrying a trust-metadata stamp.
|
|
#
|
|
# An Artifact knows how to attach itself to a message, idempotently:
|
|
# it consults `message.artifacts` to skip if its filename is already
|
|
# there. The attaching verb belongs to the Artifact (the noun whose
|
|
# state the verb consults), not to a separate Attacher class.
|
|
class Artifact
|
|
attr_reader :filename, :content, :content_type, :metadata
|
|
|
|
def initialize(filename:, content:, content_type:, metadata: {})
|
|
@filename = filename
|
|
@content = content
|
|
@content_type = content_type
|
|
@metadata = metadata
|
|
end
|
|
|
|
# Idempotent attach. Returns true if newly attached, false if the
|
|
# filename was already present on the message (so callers can count
|
|
# what they actually persisted vs what was already there).
|
|
def attach_to(message)
|
|
return false if already_attached_to?(message)
|
|
|
|
message.artifacts.attach(
|
|
io: StringIO.new(content),
|
|
filename: filename,
|
|
content_type: content_type,
|
|
metadata: metadata
|
|
)
|
|
true
|
|
end
|
|
|
|
def already_attached_to?(message)
|
|
message.artifacts.any? { |a| a.filename.to_s == filename }
|
|
end
|
|
|
|
def ==(other)
|
|
other.is_a?(Artifact) &&
|
|
other.filename == filename &&
|
|
other.content == content &&
|
|
other.content_type == content_type &&
|
|
other.metadata == metadata
|
|
end
|
|
alias_method :eql?, :==
|
|
|
|
def hash
|
|
[ filename, content, content_type, metadata ].hash
|
|
end
|
|
end
|
|
end
|