Class: Rubino::Session::Repository

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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) ||
      first_user_message(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 increment_message_count!(id)
  @db[:sessions].where(id: id).update(
    message_count: Sequel[:message_count] + 1,
    updated_at: Time.now.utc.iso8601
  )
end

#latest_activeObject

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_resumableObject

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 { message_count > 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.

Returns:

  • (Boolean)


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