Class: Yorishiro::SessionStore

Inherits:
Object
  • Object
show all
Defined in:
lib/yorishiro/session_store.rb

Overview

Persists conversations to .yorishiro/sessions/.json under the launch directory so they can be resumed later (--continue / --resume / /resume). Each session is one JSON file rewritten wholesale through an atomic tmp-file rename: compaction rewrites history destructively (an append-only log would need replaying), and the atomic swap means a crash mid-write never corrupts the previously saved state.

Constant Summary collapse

DIR_NAME =
File.join(".yorishiro", "sessions")
MAX_SESSIONS =
50
SCHEMA_VERSION =
1
TITLE_MAX_LENGTH =
60

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(dir: Dir.pwd, max_sessions: MAX_SESSIONS) ⇒ SessionStore

Returns a new instance of SessionStore.



18
19
20
21
22
# File 'lib/yorishiro/session_store.rb', line 18

def initialize(dir: Dir.pwd, max_sessions: MAX_SESSIONS)
  @sessions_dir = File.join(dir, DIR_NAME)
  @max_sessions = max_sessions
  @locks = {}
end

Instance Attribute Details

#sessions_dirObject (readonly)

Returns the value of attribute sessions_dir.



24
25
26
# File 'lib/yorishiro/session_store.rb', line 24

def sessions_dir
  @sessions_dir
end

Instance Method Details

#claim(id) ⇒ Object

Take exclusive ownership of a session id for this process, so a second yorishiro resuming the same session in the same directory doesn't write over this one's history (last-writer-wins data loss). Returns true when acquired (or already held by us), false when another live process owns it. The advisory lock is released automatically on process exit. Filesystems without flock fail open to preserve the single-user behaviour.



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/yorishiro/session_store.rb', line 33

def claim(id)
  return true if @locks.key?(id)

  FileUtils.mkdir_p(@sessions_dir)
  # Held open for the process lifetime so the advisory lock persists;
  # the block form would close it and release the lock immediately.
  lock = File.open(lock_path_for(id), File::CREAT | File::RDWR, 0o644) # rubocop:disable Style/FileOpen
  if lock.flock(File::LOCK_EX | File::LOCK_NB)
    @locks[id] = lock
    true
  else
    lock.close
    false
  end
rescue SystemCallError
  true
end

#latestObject



95
96
97
# File 'lib/yorishiro/session_store.rb', line 95

def latest
  list.first
end

#listObject

All sessions, most recently updated first. Corrupt files are skipped.



100
101
102
103
104
105
# File 'lib/yorishiro/session_store.rb', line 100

def list
  Dir.glob(File.join(@sessions_dir, "*.json"))
     .filter_map { |path| parse_session(path) }
     .sort_by { |session| session[:updated_at].to_s }
     .reverse
end

#load(id) ⇒ Object

Load a session by exact id or id prefix (most recent match wins). Returns nil when missing or corrupt.



87
88
89
90
91
92
93
# File 'lib/yorishiro/session_store.rb', line 87

def load(id)
  path = path_for(id)
  path = Dir.glob(File.join(@sessions_dir, "#{id}*.json")).max unless File.exist?(path)
  return nil unless path

  parse_session(path)
end

#release_locksObject

Release every lock held by this store. The OS also releases them on process exit; this exists for tests and explicit teardown.



53
54
55
56
# File 'lib/yorishiro/session_store.rb', line 53

def release_locks
  @locks.each_value { |lock| lock.close unless lock.closed? }
  @locks.clear
end

#save(id:, messages:, provider:, model:) ⇒ Object

Write the session and return its id (generating one when nil). The original created_at is preserved across saves. Returns nil when the write fails — persistence must never break a REPL turn.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/yorishiro/session_store.rb', line 61

def save(id:, messages:, provider:, model:)
  id ||= generate_id
  claim(id)
  existing = parse_session(path_for(id))
  now = Time.now.utc.iso8601

  session = {
    version: SCHEMA_VERSION,
    id: id,
    provider: provider,
    model: model,
    created_at: existing&.dig(:created_at) || now,
    updated_at: now,
    title: title_from(messages),
    messages: messages
  }

  write_atomically(path_for(id), JSON.generate(session))
  prune!
  id
rescue SystemCallError
  nil
end