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.
-
#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
- LIKE_ESCAPE =
LIKE-pattern escape char for the SAFE id-prefix match (#333a): ‘%` and `_` are LIKE wildcards, so an unescaped `find(“%”)` matched EVERY session. id_prefix_match escapes the metacharacters and declares this as the explicit ESCAPE char so only the trailing `%` we append is a wildcard.
"\\"- 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- TITLE_MAX_CHARS =
The display/storage ceiling for a session title (#581). The auto-derive path has always truncated to this; the manual ‘/sessions rename` write seam and the `/status`/picker render seams reuse the SAME bound so a 2000-char renamed title can’t blow out the panel/picker layout.
60
Class Method Summary collapse
-
.derive_title(text, max: TITLE_MAX_CHARS) ⇒ 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, cwd: default_cwd) ⇒ 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).
-
#claim_for_resume!(row) ⇒ Object
Public re-export of the live-owner check (#347): explicit ‘–resume <id>` (the Runner) needs the SAME stomp guard auto-resume already applies, so a second process resuming a session a first live process is still writing can fork instead of interleaving writes into one malformed transcript.
-
#create(source:, model: nil, provider: nil, title: nil, parent_session_id: nil, cwd: default_cwd) ⇒ 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_resumable_for_cwd(cwd = default_cwd) ⇒ Object
Bare ‘chat` / `–continue` auto-resume target, SCOPED to the launch dir (r5 MF-4 / C-1): the latest resumable session whose stored cwd matches the current directory, never the globally-latest.
-
#list(limit: 20, status: nil, search: nil, cwd: nil, include_subagents: false) ⇒ 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.
61 62 63 |
# File 'lib/rubino/session/repository.rb', line 61 def initialize(db: nil) @db = db || Rubino.database.db end |
Class Method Details
.derive_title(text, max: TITLE_MAX_CHARS) ⇒ 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.
327 328 329 330 331 332 333 334 335 336 |
# File 'lib/rubino/session/repository.rb', line 327 def self.derive_title(text, max: TITLE_MAX_CHARS) 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, cwd: default_cwd) ⇒ 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.
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/rubino/session/repository.rb', line 96 def build(source:, model: nil, provider: nil, title: nil, parent_session_id: nil, cwd: default_cwd) now = Time.now.utc.iso8601 { id: generate_id, parent_session_id: parent_session_id, source: source, model: model, provider: provider, title: scrub_text(title), status: "active", cwd: cwd, message_count: 0, token_count: 0, created_at: now, updated_at: now, persisted: false } end |
#claim_for_resume!(row) ⇒ Object
Public re-export of the live-owner check (#347): explicit ‘–resume <id>` (the Runner) needs the SAME stomp guard auto-resume already applies, so a second process resuming a session a first live process is still writing can fork instead of interleaving writes into one malformed transcript. Atomically claims a resumable session for THIS process (#390/residual #376). The explicit-resume guard used to be a check-then-stamp: `owned_by_other_live_process?` read owner_pid, and a LATER `update(id, owner_pid:)` stamped it — a TOCTOU window where two concurrent `–resume <id>` both read the SAME dead owner_pid, both passed the guard, and both stamped+wrote the row, interleaving (user,user …) into one malformed transcript. This collapses the read and the stamp into a single compare-and-swap, mirroring Jobs::Queue#claim!: stamp owner_pid only WHILE the row still carries the owner we saw (nil or the dead pid), so exactly ONE racer’s UPDATE matches and the loser sees rowcount 0 and forks. A LIVE foreign owner is rejected up front (returns false) so the second resumer still forks rather than stomping the live writer.
Returns true iff THIS process won the claim. ‘seen_owner_pid` is the owner_pid the caller observed (passed so the CAS targets exactly that value); when nil/ours/dead the claim is attempted, when alive-and-foreign it is refused without touching the row.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/rubino/session/repository.rb', line 39 def claim_for_resume!(row) # rubocop:disable Naming/PredicateMethod -- mutating CAS (bang); the boolean reports whether THIS caller won the claim return false if live_owned_by_other?(row) seen = row[:owner_pid] now = Time.now.utc.iso8601 # CAS: only stamp if the row STILL carries the owner we saw. `seen` is # nil (unowned) or a dead pid; either way a concurrent winner has # already changed owner_pid to its own live pid, so this WHERE misses. cond = seen.nil? ? { owner_pid: nil } : { owner_pid: seen } updated = @db[:sessions] .where(id: row[:id]) .where(cond) .update(owner_pid: Process.pid, updated_at: now) updated.positive? end |
#create(source:, model: nil, provider: nil, title: nil, parent_session_id: nil, cwd: default_cwd) ⇒ Object
Creates a new session and returns its record. cwd stamps the launch directory so resume can be scoped per-cwd (r5 MF-4 / C-1); defaults to the current workspace primary root so every session records where it started.
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/rubino/session/repository.rb', line 68 def create(source:, model: nil, provider: nil, title: nil, parent_session_id: nil, cwd: default_cwd) 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: scrub_text(title), status: "active", owner_pid: Process.pid, cwd: cwd, 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. Also removes the session’s on-disk spill/paste artifacts (#374), which the DB cascade alone left ORPHANED: oversized pastes live under <home>/sessions/<id>/ and full tool-output spills under <home>/tool-results/<call_id>.txt. The tool_calls’ call_ids are captured BEFORE their rows are deleted so the matching spill files can be removed; the paste subtree is keyed by the session id directly. File removal runs AFTER the transaction commits so a rolled-back delete never strands the DB rows against deleted files.
346 347 348 349 350 351 352 353 354 355 356 357 |
# File 'lib/rubino/session/repository.rb', line 346 def destroy!(id) call_ids = @db[:tool_calls].where(session_id: id).select_map(: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 Util::SpillStore.destroy_session_files(id, call_ids: call_ids) end |
#end_session!(id) ⇒ Object
Ends a session
242 243 244 245 246 247 248 249 250 |
# File 'lib/rubino/session/repository.rb', line 242 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)
148 149 150 |
# File 'lib/rubino/session/repository.rb', line 148 def find(id) @db[:sessions].where(id_prefix_match(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).
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/rubino/session/repository.rb', line 164 def find_by_id_or_title(query) return nil if query.nil? || query.to_s.empty? id_matches = @db[:sessions].where(id_prefix_match(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
226 227 228 229 230 231 |
# File 'lib/rubino/session/repository.rb', line 226 def (id) @db[:sessions].where(id: id).update( message_count: Sequel[:message_count] + 1, updated_at: Time.now.utc.iso8601 ) end |
#latest_resumable_for_cwd(cwd = default_cwd) ⇒ Object
Bare ‘chat` / `–continue` auto-resume target, SCOPED to the launch dir (r5 MF-4 / C-1): the latest resumable session whose stored cwd matches the current directory, never the globally-latest. This is what kills “folder B silently resumes folder A”: a session started in /api carries cwd=/api and is invisible to a `chat` launched in /web, which instead finds /web’s own latest (or nil ⇒ fresh) — mirroring Claude Code/Codex’s per-cwd picker. Two sessions stamped to DIFFERENT dirs can never resolve to each other, so concurrent instances in different folders don’t stomp.
Also excludes sessions a DIFFERENT live process currently owns (status=“active” + an alive owner_pid that isn’t us): a second tab in the SAME dir must not silently latch onto the session the first tab is still writing (the two-tabs-stomp-one-session bleed). It forks a fresh session instead; the user can still reattach explicitly with ‘–resume <id>`. Compares on canonical (realpath) paths so a symlinked launch dir matches the stored root. Returns nil ⇒ caller starts fresh.
292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/rubino/session/repository.rb', line 292 def latest_resumable_for_cwd(cwd = default_cwd) target = canonical(cwd) return nil if target.nil? @db[:sessions] .where(resumable_predicate) .exclude(cwd: nil) .order(Sequel.desc(:updated_at), Sequel.desc(Sequel.lit("rowid"))) .all .find do |row| next false unless canonical(row[:cwd]) == target # Skip a session another live process is actively writing. !live_owned_by_other?(row) end end |
#list(limit: 20, status: nil, search: nil, cwd: nil, include_subagents: false) ⇒ Object
Lists sessions with optional filters. cwd scopes the listing to a single launch directory (#334): a bare ‘sessions list` defaults to the current dir so a multi-folder user only sees THIS project’s sessions, mirroring the per-cwd auto-resume picker; pass cwd: nil (the ‘–all` flag) to list every directory’s sessions as before. Compared on canonical (realpath) paths so a symlinked launch dir still matches the stored root, which means the cwd filter runs in Ruby (not SQL) AFTER the status/search predicates — the limit is therefore applied post-filter. Sessions created by the ‘task` tool’s subagent runs are tagged source=“subagent” (Agent::Runner session_source). They are internal machinery — “Use the shell tool to run exactly this…” prompt-sessions —not the user’s own conversations, so they are EXCLUDED from the user-facing list/picker by default (item 2), the way Claude Code hides its Task subagent sessions. They remain reachable by explicit id via #find / #find_by_id_or_title (which never apply this filter), so a subagent session is still resumable when the id is known.
204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/rubino/session/repository.rb', line 204 def list(limit: 20, status: nil, search: nil, cwd: nil, include_subagents: false) dataset = @db[:sessions].order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid"))) dataset = dataset.where(status: status) if status dataset = dataset.exclude(source: "subagent") unless include_subagents dataset = dataset.where(title_substring_match(search)) if search && !search.empty? return dataset.limit(limit).all if cwd.nil? target = canonical(cwd) return dataset.limit(limit).all if target.nil? dataset.all.select { |row| canonical(row[:cwd]) == target }.first(limit) 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.
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/rubino/session/repository.rb', line 118 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, cwd: session[:cwd], 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.
141 142 143 144 145 |
# File 'lib/rubino/session/repository.rb', line 141 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.
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'lib/rubino/session/repository.rb', line 259 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
219 220 221 222 223 |
# File 'lib/rubino/session/repository.rb', line 219 def update(id, **attrs) attrs[:title] = scrub_text(attrs[:title]) if attrs.key?(:title) 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
234 235 236 237 238 239 |
# File 'lib/rubino/session/repository.rb', line 234 def update_token_count!(id, token_count) @db[:sessions].where(id: id).update( token_count: token_count, updated_at: Time.now.utc.iso8601 ) end |