Eleven source files moved from ajent-rails:lib/opencode/rails/ to
opencode-rails:lib/opencode/ (flat layout — modules are Opencode::*, not
Opencode::Rails::*; matches opencode-ruby).
artifact.rb 63 LOC
exchange.rb 77 LOC
impostor.rb 48 LOC
message_artifacts.rb 133 LOC
sandbox_file.rb 81 LOC
sandbox.rb 71 LOC
session.rb 168 LOC
tool_display.rb 423 LOC
transform.rb 77 LOC
turn.rb 642 LOC
uploaded_files_prompt.rb 85 LOC
----
total 1,868 LOC
Surgical Rails strips:
exchange.rb:
Rails.event.notify(name, payload)
-> Opencode::Instrumentation.instrument(name, payload) { }
message_artifacts.rb (1 call), turn.rb (6 calls):
Rails.error.report(error, **opts)
-> Opencode::ErrorReporter.report(error, **opts)
Comments/docstrings referencing Rails.error.report / Rails.event left
in place — they document how to wire the host adapter.
ActiveSupport core_ext requires expanded in lib/opencode-rails.rb to
cover Numeric#seconds, Hash#deep_stringify_keys, String#squish/truncate,
String#demodulize. Bundle install + smoke load confirms all 12
gem-provided constants resolve cleanly.
169 lines
6.8 KiB
Ruby
169 lines
6.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Opencode
|
|
# Owns the lifecycle of an OpenCode session against a domain record.
|
|
#
|
|
# Three near-identical implementations of this lifecycle existed on
|
|
# Blackline::Conversation, Raven::Conversation, and AIGL::Trip. Each
|
|
# had subtle differences (Blackline and Raven didn't take a row-level
|
|
# lock; AIGL did). Sandi Metz flagged the shotgun surgery in the
|
|
# architectural review — a change to the lifecycle had to be made in
|
|
# three places that looked alike but disagreed on locking. This PORO
|
|
# is the consolidated role.
|
|
#
|
|
# Usage:
|
|
#
|
|
# service = Opencode::Session.new(
|
|
# conversation,
|
|
# permissions_for: ->(record) { permission_rules_for(record) },
|
|
# on_error: ->(e, **opts) { Rails.error.report(e, **opts) } # optional
|
|
# )
|
|
# session_id = service.ensure!(client) # idempotent create-or-resolve
|
|
# service.recreate!(client) # always create fresh
|
|
# service.abort!(client) # best-effort upstream abort
|
|
#
|
|
# The permissions_for: callable receives the record at mint! time and
|
|
# returns the permissions array for client.create_session. Product-
|
|
# specific scoping (e.g. AIGL's workspace_key/trip_id branching) lives
|
|
# in the caller's lambda, not in this class — that keeps Session free
|
|
# of any reference to permission-building helpers and preserves the
|
|
# rails-tier -> containers-tier boundary the design doc locks in.
|
|
#
|
|
# The on_error: callable is invoked when abort! catches an
|
|
# Opencode::Error during teardown. Callers wire their own observability
|
|
# (Rails.error.report, OpenTelemetry, Sentry, custom logging) here.
|
|
# Defaults to nil — silently swallowing teardown errors, which matches
|
|
# the pre-inversion behaviour. Substrate has no opinion about how the
|
|
# host reports.
|
|
#
|
|
# The record must respond to:
|
|
# - #title (String) — passed as session title
|
|
# - #opencode_session_id / #opencode_session_id= — string column
|
|
# - #with_lock(&block) — ActiveRecord row-level lock
|
|
# - #update! / #reload — standard ActiveRecord
|
|
# - #id — for error reporting context
|
|
class Session
|
|
def initialize(record, permissions_for:, on_error: nil)
|
|
@record = record
|
|
@permissions_for = permissions_for
|
|
@on_error = on_error
|
|
@just_created = false
|
|
end
|
|
|
|
# True iff the most recent ensure!/recreate! actually created a
|
|
# fresh upstream session (vs resolving to an existing id).
|
|
#
|
|
# Exists so consumers can distinguish "we just minted this" from
|
|
# "we found an existing one" without poking at ActiveRecord dirty
|
|
# tracking on the record. The right object to ask is the object
|
|
# that did the work; that's this one.
|
|
def just_created?
|
|
@just_created
|
|
end
|
|
|
|
# Returns the session id for the record, creating an OpenCode session
|
|
# if none exists yet. Idempotent. Race-safe via row-level locking and
|
|
# double-check shortcuts that avoid the lock entirely when an id is
|
|
# already persisted.
|
|
def ensure!(client)
|
|
@just_created = false
|
|
return @record.opencode_session_id if @record.opencode_session_id.present?
|
|
|
|
@record.with_lock do
|
|
# Double-check inside the lock: another worker may have set the
|
|
# id between our first read and acquiring the lock.
|
|
return @record.opencode_session_id if @record.opencode_session_id.present?
|
|
|
|
mint!(client)
|
|
end
|
|
rescue ActiveRecord::RecordNotUnique
|
|
# Two workers raced past the unique-index gate. The loser reloads
|
|
# to pick up the winner's id. The winner is the one that
|
|
# just_created?; the loser sees @just_created = false.
|
|
@record.reload
|
|
@record.opencode_session_id
|
|
end
|
|
|
|
# Always creates a fresh session and overwrites the persisted id.
|
|
# Used by Opencode::Turn recovery when the upstream session has gone
|
|
# stale (StaleSessionError / SessionNotFoundError).
|
|
#
|
|
# Race semantics: two concurrent recreate! callers serialize through
|
|
# with_lock; the first mints, the second observes the freshly-minted
|
|
# id (different from its own pre-lock snapshot) and returns that
|
|
# rather than minting again. Both callers converge on one upstream
|
|
# session — no orphan leak.
|
|
def recreate!(client)
|
|
@just_created = false
|
|
pre_lock_id = @record.opencode_session_id
|
|
|
|
@record.with_lock do
|
|
# If another recreate! caller minted while we were waiting for
|
|
# the lock, the id changed under us. Treat their fresh mint as
|
|
# fresh enough for us too — recreate! returns "a session that's
|
|
# newer than what I saw at method entry", not "specifically my
|
|
# own mint".
|
|
current_id = @record.opencode_session_id
|
|
if current_id.present? && current_id != pre_lock_id
|
|
return current_id
|
|
end
|
|
|
|
mint!(client)
|
|
end
|
|
end
|
|
|
|
# Best-effort upstream abort. Swallows Opencode::Error so callers
|
|
# never have to wrap this in a rescue — aborts run inside cleanup
|
|
# paths where re-raising would mask the real cause of teardown.
|
|
def abort!(client)
|
|
return unless @record.opencode_session_id.present?
|
|
|
|
client.abort_session(@record.opencode_session_id)
|
|
rescue Opencode::Error => e
|
|
@on_error&.call(e, action: "abort_session", record_id: @record.id)
|
|
end
|
|
|
|
private
|
|
|
|
# The atomic create-and-persist unit shared by ensure! and recreate!.
|
|
#
|
|
# One operation, two failure modes:
|
|
# - client.create_session raises -> nothing to clean up, re-raise
|
|
# - update! raises RecordInvalid -> upstream session exists,
|
|
# delete it before re-raising
|
|
# so we never leak orphans
|
|
#
|
|
# Sets @just_created = true on success; callers reset to false at
|
|
# method entry so a no-op call (existing id) reports false correctly.
|
|
def mint!(client)
|
|
session_id = nil
|
|
|
|
begin
|
|
result = client.create_session(title: @record.title, permissions: @permissions_for.call(@record))
|
|
session_id = extract_session_id(result)
|
|
@record.update!(opencode_session_id: session_id)
|
|
@just_created = true
|
|
session_id
|
|
rescue ActiveRecord::RecordInvalid
|
|
safely_delete(client, session_id) if session_id
|
|
raise
|
|
end
|
|
end
|
|
|
|
# OpenCode HTTP responses use symbol keys when parsed via JSON.parse
|
|
# with symbolize_names: true (Opencode::Client) but mocks/stubs in
|
|
# tests often produce string-keyed hashes. Accept both.
|
|
def extract_session_id(result)
|
|
result[:id] || result["id"]
|
|
end
|
|
|
|
def safely_delete(client, session_id)
|
|
client.delete_session(session_id)
|
|
rescue Opencode::Error
|
|
# Best-effort cleanup; the orphan may be gone already or the
|
|
# upstream is unavailable. Either way, we already have a real
|
|
# error to raise.
|
|
end
|
|
end
|
|
end
|