Class: Agent::Sessions::Adapters::Opencode

Inherits:
Base
  • Object
show all
Defined in:
lib/agent/sessions/adapters/opencode.rb

Constant Summary collapse

DATABASE_GLOB =

opencode names its database per release channel — opencode.db, opencode-stable.db — so the filename is a glob, not a constant (tokentelemetry globs the same pattern). Unverified here: this machine has only the plain name.

"opencode*.db"
SESSION_COLUMNS =
"id, directory, time_created, time_updated"

Constants inherited from Base

Base::FIDELITIES

Constants included from Enumeration

Enumeration::MAX_LINE_BYTES

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

fidelity_value, homedir_config, #initialize, #locate, #retention, #retention_source, store_configs, #warnings

Methods included from Enumeration

#bytes_for, #encode_project, #project_dir_name, #project_path_for, #session_id_from, #started_at_for, #updated_at_for

Constructor Details

This class inherits a constructor from Agent::Sessions::Adapters::Base

Class Method Details

.reader_classObject



13
# File 'lib/agent/sessions/adapters/opencode.rb', line 13

def self.reader_class = Readers::Opencode

Instance Method Details

#base_dirObject

The declared default stands unless another candidate actually holds a database. Falling back to it rather than to the first candidate that merely exists keeps where printing a concrete, conventional path on a machine with no opencode at all.



27
28
29
30
31
32
# File 'lib/agent/sessions/adapters/opencode.rb', line 27

def base_dir
  @base_dir ||= begin
    probes = ([resolver.home(:opencode)] + resolver.candidates(:opencode)).map(&:to_s).uniq
    probes.find { |dir| Dir.glob(File.join(escape_glob(dir), DATABASE_GLOB)).any? } || resolver.home(:opencode).to_s
  end
end

#project_pathsObject

ORDER BY matches the sorted order Base guarantees, so projects output is stable and diffable whichever adapter answers it. is_a?(String) excludes a row whose directory is NULL (build_db_session's guard, same rule 2 container check) rather than letting a literal nil sort in among real paths — "excluded, not nil", matching Base's project_paths docstring. .uniq is needed on top of SQL's own DISTINCT: SQLite's DISTINCT treats a BLOB and a byte-identical TEXT value as different rows (confirmed directly — typeof reports "text" vs "blob" for the same bytes even though the sqlite3 gem returns both to Ruby as String, see build_db_session's comment), so without this a blob/text pair with identical bytes would surface as two entries where Base's own .uniq.sort would collapse them to one.



134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/agent/sessions/adapters/opencode.rb', line 134

def project_paths
  paths = []
  database_paths.each do |db_path|
    each_session_row(db_path, "SELECT DISTINCT directory FROM session ORDER BY directory") do |row|
      paths << row.first if row.first.is_a?(String)
    end
  end
  # Sorted after the union, not per database: SQL's ORDER BY only orders
  # within one file, and two channel databases concatenated would leave
  # `projects` unsorted — the one thing Base guarantees about it.
  paths.uniq.sort
end

#sessionsObject

Sessions are rows, not files, so the Base glob enumeration is replaced by a deferred query: it runs at first consumption, and only if the database exists. The existence check comes FIRST so machines without opencode never need sqlite3 at all (design doc section 9).

Row order is deliberately unspecified: no ORDER BY, so rows arrive in whatever order SQLite's own scan produces (rowid order, absent an index that would change it) — unlike the other six adapters, which are path-sorted for free by Dir.glob. Invisible today because nothing here sorts before Task 10 does its own sort_by(&:updated_at); stated so a future caller of THIS method directly does not come to depend on insertion order looking stable.

The gap between this check and the open below is a real TOCTOU window — opencode could delete or migrate the file in between — but the open that follows a vanished file raises SQLite3::CantOpenException (verified directly), which each_session_row already turns into UnreadableStore. That is judged the right answer, not a bug to special-case: unlike Base's own glob-then-stat race (one file silently missing from a multi-file listing, so build_session's rescue drops it and moves on), a vanished DATABASE is the store's only source for every session, so there is nothing partial to return — "the store I just confirmed exists is now unreadable" is what happened, and UnreadableStore says exactly that.

Opens once per consumption, not once per instance: sessions, sessions_for_project and project_paths each open, query and close their own connection through each_session_row. That costs an extra open when a caller uses more than one of the three, but keeps every method independently correct rather than threading a shared handle through them — and Enumerator.new's block does not even run until the RETURNED lazy enumerator is consumed, so a caller that builds sessions and never touches it opens nothing at all. Confirmed empirically (not just assumed from Enumerator's docs) that the ensure db&.close inside each_session_row fires promptly either way a caller can stop early — .lazy.first(n) and an external each { break } both unwind the generator fiber immediately, before the outer call returns — so a caller taking sessions.first never leaves a connection open waiting for GC to reclaim the fiber.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/agent/sessions/adapters/opencode.rb', line 81

def sessions
  paths = database_paths
  return [].lazy if paths.empty?

  Enumerator.new do |yielder|
    seen = {}
    paths.each do |db_path|
      each_session_row(db_path, "SELECT #{SESSION_COLUMNS} FROM session") do |row|
        # Channel databases can hold the same session — a store migrated
        # between channels keeps both files. Deduped by id, first
        # database wins, so one session is one row to a caller counting
        # them. tokentelemetry dedups the same way for the same reason.
        next if seen[row.first]

        seen[row.first] = true
        yielder << build_db_session(db_path, row)
      end
    end
  end.lazy
end

#sessions_for_project(dir) ⇒ Object

The directory column holds the full recorded path, so filtering is a WHERE clause instead of the Base read-and-compare loop.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/agent/sessions/adapters/opencode.rb', line 104

def sessions_for_project(dir)
  dir = File.expand_path(dir)
  paths = database_paths
  return [].lazy if paths.empty?

  Enumerator.new do |yielder|
    seen = {}
    paths.each do |db_path|
      each_session_row(db_path, "SELECT #{SESSION_COLUMNS} FROM session WHERE directory = ?", [dir]) do |row|
        next if seen[row.first]

        seen[row.first] = true
        yielder << build_db_session(db_path, row)
      end
    end
  end.lazy
end

#verifyObject

Base checks the declared path literally, which gets a machine holding only a channel-named database wrong twice: its "is this agent installed" gate sees no declared layer and skips the store checks entirely, and the store check itself would report :fail on a real store that is merely called something the declaration did not predict.

Any file matching the glob satisfies the claim. The detail names what was actually found, so a non-canonical filename is visible rather than merely tolerated — the same reason detail_for prints a file count.



156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/agent/sessions/adapters/opencode.rb', line 156

def verify
  found = database_paths
  return super if found.empty?

  checks = super
  database = checks.find { |candidate| candidate.claim == "store database exists" }
  return checks.map { |c| c.claim == database&.claim && !c.pass? ? passing_database(found) : c } if database

  # The skip gate fired: Base returned one :skip and never looked at the
  # stores. Answer the store claim it never asked.
  [passing_database(found)] + checks.reject { |c| c.claim == "agent is installed" }
end