Class: Rubino::Session::Repository
- Inherits:
-
Object
- Object
- Rubino::Session::Repository
- Defined in:
- lib/rubino/session/repository.rb
Overview
Thin CRUD wrapper over the ‘sessions` table. All session persistence goes through this class; callers should not touch the dataset directly.
Notes:
-
#find supports prefix matching on the UUID so short ids from the CLI resolve to a full session row.
-
#latest_active is used to resume the most recently touched session.
-
#destroy! cascades manually to events, tool_calls, messages, session_summaries and runs inside a single transaction (no FK cascade in schema; the runs FK would otherwise block the session delete).
Constant Summary collapse
- TITLE_MIN_CHARS =
A first prompt shorter than this is junk for titling purposes (#128): a throwaway “y”/“ok” the user immediately interrupted would otherwise become the session title and a useless one-char ‘–resume “y”` matcher.
3
Class Method Summary collapse
-
.derive_title(text, max: 60) ⇒ Object
Derives a short, human-readable session title from the first user message.
Instance Method Summary collapse
-
#build(source:, model: nil, provider: nil, title: nil, parent_session_id: nil) ⇒ Object
Builds an UNSAVED session record (in-memory only) with a real id, so the CLI can open ‘chat` without persisting a row until the user actually sends a message (#144).
-
#create(source:, model: nil, provider: nil, title: nil, parent_session_id: nil) ⇒ Object
Creates a new session and returns its record.
-
#destroy!(id) ⇒ Object
Deletes a session and all related records.
-
#end_session!(id) ⇒ Object
Ends a session.
-
#find(id) ⇒ Object
Finds a session by ID (supports prefix matching).
-
#find_by_id_or_title(query) ⇒ Object
Resolves a user-supplied query to a session: tries ID prefix first (handles “abc12345” style short IDs), then falls back to a case- insensitive substring match across the 50 most recent sessions — against the title AND the full first user message.
-
#increment_message_count!(id) ⇒ Object
Increments message count.
-
#initialize(db: nil) ⇒ Repository
constructor
A new instance of Repository.
-
#latest_active ⇒ Object
Returns the most recent active session, if any.
-
#latest_resumable ⇒ Object
Returns the most recent session worth resuming on a bare ‘chat`: the last session that actually has messages, regardless of status, so a closed terminal (status still “active”) OR a cleanly ended session can both be continued.
-
#list(limit: 20, status: nil, search: nil) ⇒ Object
Lists sessions with optional filters.
-
#persist!(session) ⇒ Object
Inserts a session row built by #build if it isn’t already in the DB.
-
#persisted?(id) ⇒ Boolean
True when a row with this id exists in the sessions table.
-
#reap_orphaned_active! ⇒ Object
Reaps orphaned sessions: any row still “active” whose owning process is gone is stamped “ended” (#11).
-
#update(id, **attrs) ⇒ Object
Updates a session’s attributes.
-
#update_token_count!(id, token_count) ⇒ Object
Updates token count.
Constructor Details
#initialize(db: nil) ⇒ Repository
Returns a new instance of Repository.
19 20 21 |
# File 'lib/rubino/session/repository.rb', line 19 def initialize(db: nil) @db = db || Rubino.database.db end |
Class Method Details
.derive_title(text, max: 60) ⇒ Object
Derives a short, human-readable session title from the first user message. Deterministic and model-free (#103): collapse whitespace, strip a leading slash-command word, take the first line, and truncate on a word boundary. Returns nil for empty/blank input — and for junk-short input (#128) — so the caller leaves the session untitled; the next MEANINGFUL prompt titles it instead (Lifecycle#maybe_set_title retries every turn until a title sticks), and the resume hint falls back to the session id.
239 240 241 242 243 244 245 246 247 248 |
# File 'lib/rubino/session/repository.rb', line 239 def self.derive_title(text, max: 60) cleaned = text.to_s.split("\n").first.to_s.strip.gsub(/\s+/, " ") cleaned = cleaned.sub(%r{\A/\S+\s*}, "") # drop a leading slash command return nil if cleaned.length < TITLE_MIN_CHARS return cleaned if cleaned.length <= max truncated = cleaned[0, max].sub(/\s+\S*\z/, "") truncated = cleaned[0, max] if truncated.empty? "#{truncated}…" end |
Instance Method Details
#build(source:, model: nil, provider: nil, title: nil, parent_session_id: nil) ⇒ Object
Builds an UNSAVED session record (in-memory only) with a real id, so the CLI can open ‘chat` without persisting a row until the user actually sends a message (#144). The row is inserted lazily by #persist! on the first message; a session the user opens and immediately exits never touches the DB, so `/sessions` stays free of (untitled)/0-msg junk.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/rubino/session/repository.rb', line 51 def build(source:, model: nil, provider: nil, title: nil, parent_session_id: nil) now = Time.now.utc.iso8601 { id: generate_id, parent_session_id: parent_session_id, source: source, model: model, provider: provider, title: title, status: "active", message_count: 0, token_count: 0, created_at: now, updated_at: now, persisted: false } end |
#create(source:, model: nil, provider: nil, title: nil, parent_session_id: nil) ⇒ Object
Creates a new session and returns its record
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/rubino/session/repository.rb', line 24 def create(source:, model: nil, provider: nil, title: nil, parent_session_id: nil) now = Time.now.utc.iso8601 id = generate_id @db[:sessions].insert( id: id, parent_session_id: parent_session_id, source: source, model: model, provider: provider, title: title, status: "active", owner_pid: Process.pid, message_count: 0, token_count: 0, created_at: now, updated_at: now ) find(id) end |
#destroy!(id) ⇒ Object
Deletes a session and all related records
251 252 253 254 255 256 257 258 259 260 |
# File 'lib/rubino/session/repository.rb', line 251 def destroy!(id) @db.transaction do @db[:events].where(session_id: id).delete @db[:tool_calls].where(session_id: id).delete @db[:messages].where(session_id: id).delete @db[:session_summaries].where(session_id: id).delete @db[:runs].where(session_id: id).delete @db[:sessions].where(id: id).delete end end |
#end_session!(id) ⇒ Object
Ends a session
172 173 174 175 176 177 178 179 180 |
# File 'lib/rubino/session/repository.rb', line 172 def end_session!(id) now = Time.now.utc.iso8601 @db[:sessions].where(id: id).update( status: "ended", ended_at: now, owner_pid: nil, updated_at: now ) end |
#find(id) ⇒ Object
Finds a session by ID (supports prefix matching)
101 102 103 |
# File 'lib/rubino/session/repository.rb', line 101 def find(id) @db[:sessions].where(Sequel.like(:id, "#{id}%")).first end |
#find_by_id_or_title(query) ⇒ Object
Resolves a user-supplied query to a session: tries ID prefix first (handles “abc12345” style short IDs), then falls back to a case- insensitive substring match across the 50 most recent sessions —against the title AND the full first user message. The stored title is truncated (~60 chars), so a memorable word from the TAIL of a long first prompt would otherwise silently fail to resume (#70). Returns the session row or nil. Centralised so the CLI Runner and the TUI history loader agree on what ‘–resume <query>` accepts.
Raises AmbiguousSessionError when >1 session matches, so the CLI can show the candidates instead of silently picking the first row — see issue triaged from the audit (#116).
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/rubino/session/repository.rb', line 117 def find_by_id_or_title(query) return nil if query.nil? || query.to_s.empty? id_matches = @db[:sessions].where(Sequel.like(:id, "#{query}%")).all if id_matches.size > 1 raise AmbiguousSessionError.new(query, id_matches) elsif id_matches.size == 1 return id_matches.first end needle = query.to_s.downcase title_matches = list(limit: 50).select do |s| s[:title]&.downcase&.include?(needle) || (s[:id])&.downcase&.include?(needle) end if title_matches.size > 1 raise AmbiguousSessionError.new(query, title_matches) elsif title_matches.size == 1 return title_matches.first end nil end |
#increment_message_count!(id) ⇒ Object
Increments message count
156 157 158 159 160 161 |
# File 'lib/rubino/session/repository.rb', line 156 def (id) @db[:sessions].where(id: id).update( message_count: Sequel[:message_count] + 1, updated_at: Time.now.utc.iso8601 ) end |
#latest_active ⇒ Object
Returns the most recent active session, if any
207 208 209 210 211 212 |
# File 'lib/rubino/session/repository.rb', line 207 def latest_active @db[:sessions] .where(status: "active") .order(Sequel.desc(:updated_at), Sequel.desc(Sequel.lit("rowid"))) .first end |
#latest_resumable ⇒ Object
Returns the most recent session worth resuming on a bare ‘chat`: the last session that actually has messages, regardless of status, so a closed terminal (status still “active”) OR a cleanly ended session can both be continued. Empty 0-message sessions are skipped so a stray earlier launch never shadows the real conversation (#99). Returns nil on a true first run, which the CLI uses to fall back to the welcome panel.
220 221 222 223 224 225 |
# File 'lib/rubino/session/repository.rb', line 220 def latest_resumable @db[:sessions] .where { > 0 } .order(Sequel.desc(:updated_at), Sequel.desc(Sequel.lit("rowid"))) .first end |
#list(limit: 20, status: nil, search: nil) ⇒ Object
Lists sessions with optional filters
142 143 144 145 146 147 |
# File 'lib/rubino/session/repository.rb', line 142 def list(limit: 20, status: nil, search: nil) dataset = @db[:sessions].order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid"))).limit(limit) dataset = dataset.where(status: status) if status dataset = dataset.where(Sequel.like(:title, "%#{search}%")) if search && !search.empty? dataset.all end |
#persist!(session) ⇒ Object
Inserts a session row built by #build if it isn’t already in the DB. Idempotent: a no-op once persisted (the common per-message path checks this first). Returns the (now persisted) session record.
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/rubino/session/repository.rb', line 72 def persist!(session) return session if session[:persisted] || persisted?(session[:id]) @db[:sessions].insert( id: session[:id], parent_session_id: session[:parent_session_id], source: session[:source], model: session[:model], provider: session[:provider], title: session[:title], status: session[:status] || "active", owner_pid: Process.pid, message_count: 0, token_count: 0, created_at: session[:created_at] || Time.now.utc.iso8601, updated_at: Time.now.utc.iso8601 ) session[:persisted] = true session end |
#persisted?(id) ⇒ Boolean
True when a row with this id exists in the sessions table.
94 95 96 97 98 |
# File 'lib/rubino/session/repository.rb', line 94 def persisted?(id) return false if id.nil? !@db[:sessions].where(id: id).empty? end |
#reap_orphaned_active! ⇒ Object
Reaps orphaned sessions: any row still “active” whose owning process is gone is stamped “ended” (#11). This covers the un-trappable hard kill (SIGKILL) and a closed terminal whose SIGHUP never reached the process, where neither the clean-exit path nor the signal traps ran. Rows owned by a live process (including the current one) and rows with no recorded pid (pre-#11 / future sources) are left untouched. Called lazily before listing/resuming sessions; best-effort, returns the number reaped.
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/rubino/session/repository.rb', line 189 def reap_orphaned_active! reaped = 0 @db[:sessions] .where(status: "active") .exclude(owner_pid: nil) .select(:id, :owner_pid) .each do |row| next if process_alive?(row[:owner_pid]) end_session!(row[:id]) reaped += 1 end reaped rescue StandardError reaped end |
#update(id, **attrs) ⇒ Object
Updates a session’s attributes
150 151 152 153 |
# File 'lib/rubino/session/repository.rb', line 150 def update(id, **attrs) attrs[:updated_at] = Time.now.utc.iso8601 @db[:sessions].where(id: id).update(attrs) end |
#update_token_count!(id, token_count) ⇒ Object
Updates token count
164 165 166 167 168 169 |
# File 'lib/rubino/session/repository.rb', line 164 def update_token_count!(id, token_count) @db[:sessions].where(id: id).update( token_count: token_count, updated_at: Time.now.utc.iso8601 ) end |