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:
63
lib/opencode/artifact.rb
Normal file
63
lib/opencode/artifact.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user