Class: Octo::SessionManager
- Inherits:
-
Object
- Object
- Octo::SessionManager
- Defined in:
- lib/octo/session_manager.rb
Constant Summary collapse
- SESSIONS_DIR =
File.join(Dir.home, ".octo", "sessions")
Class Method Summary collapse
-
.generate_id ⇒ Object
Generate a new unique session ID (16-char hex string).
Instance Method Summary collapse
-
#all_sessions(current_dir: nil, limit: nil) ⇒ Object
All sessions from disk, newest-first (sorted by created_at).
-
#append_message_log(session_id, messages) ⇒ Object
── Incremental message log (.jsonl) for crash recovery ──────────────────.
-
#chunks_for_current(session_id, created_at) ⇒ Array<Hash>
Discover all chunk MD files on disk for a given session.
-
#cleanup(days: 90) ⇒ Object
Delete sessions not accessed within the given number of days (default: 90).
-
#cleanup_by_count(keep:) ⇒ Object
Keep only the most recent N sessions by created_at; delete the rest.
-
#delete(session_id) ⇒ Object
Physical delete — removes disk file + associated chunk files.
- #delete_message_log(session_id) ⇒ Object
-
#delete_session_with_chunks(json_filepath) ⇒ Object
Delete a session JSON file and all its associated chunk MD files.
- #ensure_sessions_dir ⇒ Object
-
#files_for(session_id) ⇒ Object
Return the on-disk files associated with a session: the main JSON file and any “base-chunk-*.md” archive files.
- #generate_filename(session_id, created_at) ⇒ Object
-
#initialize(sessions_dir: nil) ⇒ SessionManager
constructor
A new instance of SessionManager.
-
#last_saved_path ⇒ Object
Path of the last saved session file.
-
#latest_for_directory(working_dir) ⇒ Object
Return the most recent session for a given working directory, or nil.
-
#load(session_id) ⇒ Object
Load a specific session by ID.
- #load_session_file(filepath) ⇒ Object
-
#next_chunk_index(session_id, created_at) ⇒ Object
Next unused chunk index for a session, derived from disk.
- #read_message_log(session_id) ⇒ Object
-
#recover_jsonl_sessions ⇒ Object
Scan for orphaned .jsonl files and merge their messages back into the corresponding session .json files.
-
#save(session_data) ⇒ Object
Save a session.
-
#write_chunk(session_id, created_at, chunk_index, md_content) ⇒ Object
Write a chunk MD file to disk.
Constructor Details
#initialize(sessions_dir: nil) ⇒ SessionManager
Returns a new instance of SessionManager.
19 20 21 22 |
# File 'lib/octo/session_manager.rb', line 19 def initialize(sessions_dir: nil) @sessions_dir = sessions_dir || SESSIONS_DIR ensure_sessions_dir end |
Class Method Details
.generate_id ⇒ Object
Generate a new unique session ID (16-char hex string). This is the single authoritative source for session IDs — all components (Agent, SessionRegistry) should receive an ID generated here rather than creating their own.
15 16 17 |
# File 'lib/octo/session_manager.rb', line 15 def self.generate_id SecureRandom.hex(8) end |
Instance Method Details
#all_sessions(current_dir: nil, limit: nil) ⇒ Object
All sessions from disk, newest-first (sorted by created_at). Optional filters:
current_dir: (String) if given, sessions matching working_dir come first
limit: (Integer) max number of sessions to return
152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/octo/session_manager.rb', line 152 def all_sessions(current_dir: nil, limit: nil) sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath| load_session_file(filepath) end.sort_by { |s| s[:created_at] || "" }.reverse if current_dir current_sessions = sessions.select { |s| s[:working_dir] == current_dir } other_sessions = sessions.reject { |s| s[:working_dir] == current_dir } sessions = current_sessions + other_sessions end limit ? sessions.first(limit) : sessions end |
#append_message_log(session_id, messages) ⇒ Object
── Incremental message log (.jsonl) for crash recovery ──────────────────
During an agent run, messages are appended to a per-session .jsonl file after each iteration (think + act + observe). On normal completion the .jsonl is deleted because the full session is saved to .json. If the server crashes (kill -9, OOM, etc.) the .jsonl survives and is merged back into the session on next startup.
Each line is a standalone JSON object: { “t”: unix_timestamp, “msg”: message_hash } This makes the file resilient to partial writes — we can parse every complete line and drop the trailing incomplete one.
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/octo/session_manager.rb', line 211 def (session_id, ) return if .nil? || .empty? path = jsonl_path(session_id) File.open(path, "a") do |f| .each do |msg| f.puts JSON.generate({ t: Time.now.to_f, msg: msg }) end end rescue => e Octo::Logger.warn("session_manager.append_message_log_failed", session_id: session_id, error: e. ) end |
#chunks_for_current(session_id, created_at) ⇒ Array<Hash>
Discover all chunk MD files on disk for a given session. Returns them sorted by chunk index ascending (oldest first).
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/octo/session_manager.rb', line 102 def chunks_for_current(session_id, created_at) return [] unless session_id && created_at base = chunk_base_name(session_id, created_at) pattern = File.join(@sessions_dir, "#{base}-chunk-*.md") Dir.glob(pattern).filter_map do |path| basename = File.basename(path) # Extract integer index from "<base>-chunk-<N>.md" m = basename.match(/-chunk-(\d+)\.md\z/) next nil unless m { index: m[1].to_i, path: path, basename: basename, topics: read_chunk_topics(path) } end.sort_by { |c| c[:index] } end |
#cleanup(days: 90) ⇒ Object
Delete sessions not accessed within the given number of days (default: 90). Returns count of deleted sessions.
173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/octo/session_manager.rb', line 173 def cleanup(days: 90) cutoff = Time.now - (days * 24 * 60 * 60) deleted = 0 Dir.glob(File.join(@sessions_dir, "*.json")).each do |filepath| session = load_session_file(filepath) next unless session if Time.parse(session[:updated_at]) < cutoff delete_session_with_chunks(filepath) deleted += 1 end end deleted end |
#cleanup_by_count(keep:) ⇒ Object
Keep only the most recent N sessions by created_at; delete the rest. Returns count of deleted sessions.
189 190 191 192 193 194 195 196 197 |
# File 'lib/octo/session_manager.rb', line 189 def cleanup_by_count(keep:) sessions = all_sessions # already sorted newest-first return 0 if sessions.size <= keep sessions[keep..].each do |session| filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at])) delete_session_with_chunks(filepath) if File.exist?(filepath) end.size end |
#delete(session_id) ⇒ Object
Physical delete — removes disk file + associated chunk files. Returns true if found and deleted, false if not found.
56 57 58 59 60 61 62 63 |
# File 'lib/octo/session_manager.rb', line 56 def delete(session_id) session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) } return false unless session filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at])) delete_session_with_chunks(filepath) true end |
#delete_message_log(session_id) ⇒ Object
247 248 249 250 251 252 253 254 255 |
# File 'lib/octo/session_manager.rb', line 247 def (session_id) path = jsonl_path(session_id) File.delete(path) if File.exist?(path) rescue => e Octo::Logger.warn("session_manager.delete_message_log_failed", session_id: session_id, error: e. ) end |
#delete_session_with_chunks(json_filepath) ⇒ Object
Delete a session JSON file and all its associated chunk MD files.
373 374 375 376 377 |
# File 'lib/octo/session_manager.rb', line 373 def delete_session_with_chunks(json_filepath) File.delete(json_filepath) if File.exist?(json_filepath) base = File.basename(json_filepath, ".json") Dir.glob(File.join(@sessions_dir, "#{base}-chunk-*.md")).each { |f| File.delete(f) } end |
#ensure_sessions_dir ⇒ Object
319 320 321 |
# File 'lib/octo/session_manager.rb', line 319 def ensure_sessions_dir FileUtils.mkdir_p(@sessions_dir) unless Dir.exist?(@sessions_dir) end |
#files_for(session_id) ⇒ Object
Return the on-disk files associated with a session: the main JSON file and any “base-chunk-*.md” archive files. Used by the export / download endpoint so the UI can bundle everything a user may need for debugging. Returns nil if the session is not found, or a Hash:
{
session: Hash, # the loaded session metadata
json_path: String, # absolute path to session.json
chunks: [String] # sorted absolute paths to chunk *.md files
}
74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/octo/session_manager.rb', line 74 def files_for(session_id) session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) } return nil unless session json_path = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at])) return nil unless File.exist?(json_path) base = File.basename(json_path, ".json") chunks = Dir.glob(File.join(@sessions_dir, "#{base}-chunk-*.md")).sort { session: session, json_path: json_path, chunks: chunks } end |
#generate_filename(session_id, created_at) ⇒ Object
323 324 325 |
# File 'lib/octo/session_manager.rb', line 323 def generate_filename(session_id, created_at) "#{chunk_base_name(session_id, created_at)}.json" end |
#last_saved_path ⇒ Object
Path of the last saved session file.
45 46 47 |
# File 'lib/octo/session_manager.rb', line 45 def last_saved_path @last_saved_path end |
#latest_for_directory(working_dir) ⇒ Object
Return the most recent session for a given working directory, or nil.
167 168 169 |
# File 'lib/octo/session_manager.rb', line 167 def latest_for_directory(working_dir) all_sessions(current_dir: working_dir).first end |
#load(session_id) ⇒ Object
Load a specific session by ID. Returns nil if not found.
50 51 52 |
# File 'lib/octo/session_manager.rb', line 50 def load(session_id) all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) } end |
#load_session_file(filepath) ⇒ Object
379 380 381 382 383 |
# File 'lib/octo/session_manager.rb', line 379 def load_session_file(filepath) JSON.parse(File.read(filepath), symbolize_names: true) rescue JSON::ParserError, Errno::ENOENT nil end |
#next_chunk_index(session_id, created_at) ⇒ Object
Next unused chunk index for a session, derived from disk. This is the ONLY correct way to compute the next chunk index —counting compressed_summary messages in history caps at 1 after the second compression (rebuild keeps only the latest summary) and in-memory counters reset on process restart.
128 129 130 131 |
# File 'lib/octo/session_manager.rb', line 128 def next_chunk_index(session_id, created_at) existing = chunks_for_current(session_id, created_at) (existing.map { |c| c[:index] }.max || 0) + 1 end |
#read_message_log(session_id) ⇒ Object
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/octo/session_manager.rb', line 227 def (session_id) path = jsonl_path(session_id) return [] unless File.exist?(path) File.readlines(path).filter_map do |line| next if line.strip.empty? parsed = JSON.parse(line, symbolize_names: true) parsed[:msg] rescue JSON::ParserError nil end rescue => e Octo::Logger.warn("session_manager.read_message_log_failed", session_id: session_id, error: e. ) [] end |
#recover_jsonl_sessions ⇒ Object
Scan for orphaned .jsonl files and merge their messages back into the corresponding session .json files. Called once at server startup.
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/octo/session_manager.rb', line 259 def recover_jsonl_sessions jsonl_files = Dir.glob(File.join(@sessions_dir, "*.jsonl")) return 0 if jsonl_files.empty? recovered = 0 jsonl_files.each do |path| session_id = File.basename(path, ".jsonl") session = load(session_id) unless session Octo::Logger.warn("session_manager.recover_jsonl_no_session", session_id: session_id, path: path ) next end = (session_id) next if .empty? existing = session[:messages] || [] # Avoid duplicates: if the last N messages of existing already match # the first N of log_messages, skip the overlap. merged = merge_without_duplicates(existing, ) session[:messages] = merged session[:updated_at] = Time.now.iso8601 save(session) (session_id) recovered += 1 Octo::Logger.info("session_manager.recovered_from_jsonl", session_id: session_id, messages_recovered: .size, total_messages: merged.size ) end recovered end |
#save(session_data) ⇒ Object
Save a session. Returns the file path.
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/octo/session_manager.rb', line 25 def save(session_data) filename = generate_filename(session_data[:session_id], session_data[:created_at]) filepath = File.join(@sessions_dir, filename) File.write(filepath, JSON.pretty_generate(session_data)) FileUtils.chmod(0o600, filepath) @last_saved_path = filepath # Keep only the most recent 200 sessions (best-effort, never block save) begin cleanup_by_count(keep: 200) rescue Exception # rubocop:disable Lint/RescueException # Cleanup is non-critical; swallow all errors (including AgentInterrupted) end filepath end |
#write_chunk(session_id, created_at, chunk_index, md_content) ⇒ Object
Write a chunk MD file to disk. Returns the absolute path. Caller is responsible for generating the MD content — this method only handles filesystem concerns (path assembly, write, chmod).
136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/octo/session_manager.rb', line 136 def write_chunk(session_id, created_at, chunk_index, md_content) return nil unless session_id && created_at base = chunk_base_name(session_id, created_at) chunk_path = File.join(@sessions_dir, "#{base}-chunk-#{chunk_index}.md") File.write(chunk_path, md_content) FileUtils.chmod(0o600, chunk_path) chunk_path end |