riffer-rig 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ class Riffer::Rig::Loader
21
21
  # @rbs @extensions_enabled: bool
22
22
  # @rbs @gem_extensions: Array[Riffer::Rig::Extension]
23
23
  # @rbs @file_state: Hash[String, Time?]?
24
+ # @rbs @reload_mode: (:auto | :manual | nil)
24
25
 
25
26
  # @dynamic tracked_files
26
27
  attr_reader :tracked_files #: Array[String]
@@ -37,6 +38,8 @@ class Riffer::Rig::Loader
37
38
  # @rbs tools: Array[String]?
38
39
  # @rbs max_steps: Integer?
39
40
  # @rbs store: Riffer::Rig::Stores::_Store | nil
41
+ # @rbs reload: (:auto | :manual | nil)
42
+ # @rbs snapshot: Hash[Symbol, untyped]?
40
43
  # @rbs return: Riffer::Rig::Runtime
41
44
  def self.runtime(
42
45
  cwd:,
@@ -50,10 +53,20 @@ class Riffer::Rig::Loader
50
53
  agents_md: true,
51
54
  tools: nil,
52
55
  max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS,
53
- store: Riffer::Rig::Stores::JSONL.new
56
+ store: Riffer::Rig::Stores::JSONL.new,
57
+ reload: nil,
58
+ snapshot: nil
54
59
  )
55
- new(cwd:, host:, env:, home:, riffer_config:, store:)
56
- .runtime(model:, extensions:, skills:, agents_md:, tools:, max_steps:)
60
+ new(cwd:, host:, env:, home:, riffer_config:, store:).runtime(
61
+ model:,
62
+ extensions:,
63
+ skills:,
64
+ agents_md:,
65
+ tools:,
66
+ max_steps:,
67
+ reload:,
68
+ snapshot:
69
+ )
57
70
  end
58
71
 
59
72
  # @rbs cwd: String
@@ -84,6 +97,7 @@ class Riffer::Rig::Loader
84
97
  @extensions_enabled = true
85
98
  @gem_extensions = []
86
99
  @file_state = nil
100
+ @reload_mode = nil
87
101
  end
88
102
 
89
103
  # @rbs model: String?
@@ -92,6 +106,8 @@ class Riffer::Rig::Loader
92
106
  # @rbs agents_md: bool
93
107
  # @rbs tools: Array[String]?
94
108
  # @rbs max_steps: Integer?
109
+ # @rbs reload: (:auto | :manual | nil)
110
+ # @rbs snapshot: Hash[Symbol, untyped]?
95
111
  # @rbs return: Riffer::Rig::Runtime
96
112
  def runtime(
97
113
  model: nil,
@@ -99,7 +115,9 @@ class Riffer::Rig::Loader
99
115
  skills: true,
100
116
  agents_md: true,
101
117
  tools: nil,
102
- max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
118
+ max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS,
119
+ reload: nil,
120
+ snapshot: nil
103
121
  )
104
122
  settings = merged_settings
105
123
  document = Riffer::Rig::Settings::Document.new(settings)
@@ -108,13 +126,14 @@ class Riffer::Rig::Loader
108
126
  ensure_sdk(provider)
109
127
  credentials = { provider.to_sym => credentials_for(provider) }
110
128
  @extensions_enabled = extensions
129
+ @reload_mode = reload
111
130
  @stripped = { skills: skills, agents_md: agents_md }.reject { |_name, kept| kept }.keys
112
131
  loaded = Riffer::Rig::Bundled::BY_NAME.except(*document.disabled.map(&:to_sym), *@stripped).values
113
132
  @tracked_files = []
114
133
  @gem_extensions = []
115
134
  loaded += load_rig_files(document.autoload) if extensions
116
135
 
117
- recorder = recorder_for(document)
136
+ recorder = recorder_for(document, resumed: !snapshot.nil?)
118
137
  loaded << Riffer::Rig::Stores::Recorder.extension(recorder) if recorder
119
138
 
120
139
  built = Riffer::Rig::Runtime.new(
@@ -129,25 +148,98 @@ class Riffer::Rig::Loader
129
148
  pricing: document.models,
130
149
  riffer_config: @riffer_config,
131
150
  model_options: Riffer::Rig::Settings.model_options(selected, document.reasoning),
132
- native_tools: document.native_tools
151
+ native_tools: document.native_tools,
152
+ snapshot: snapshot
133
153
  )
134
154
  built.install_command(Riffer::Rig::Commands::Reload.command(self))
155
+ built.install_reload_check(->(runtime) { automatic_reload(runtime) }) unless reload_mode(document) == :manual
135
156
  @file_state = file_state
136
157
  recorder&.attach(built)
137
158
  built
138
159
  end
139
160
 
161
+ # @rbs model: String?
162
+ # @rbs extensions: bool
163
+ # @rbs skills: bool
164
+ # @rbs agents_md: bool
165
+ # @rbs tools: Array[String]?
166
+ # @rbs max_steps: Integer?
167
+ # @rbs return: Riffer::Rig::Runtime?
168
+ def continue(
169
+ model: nil,
170
+ extensions: true,
171
+ skills: true,
172
+ agents_md: true,
173
+ tools: nil,
174
+ max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
175
+ )
176
+ store = @store
177
+ return nil unless store
178
+
179
+ latest = store.list(cwd: @cwd).max_by { |header| header.updated || Time.at(0) }
180
+ return nil unless latest
181
+
182
+ resume(latest.id, model:, extensions:, skills:, agents_md:, tools:, max_steps:)
183
+ end
184
+
185
+ # A resume carries the history over and nothing else: the settings,
186
+ # credentials, extensions and tools are today's.
187
+ # @rbs id: String
188
+ # @rbs model: String?
189
+ # @rbs extensions: bool
190
+ # @rbs skills: bool
191
+ # @rbs agents_md: bool
192
+ # @rbs tools: Array[String]?
193
+ # @rbs max_steps: Integer?
194
+ # @rbs return: Riffer::Rig::Runtime?
195
+ def resume(
196
+ id,
197
+ model: nil,
198
+ extensions: true,
199
+ skills: true,
200
+ agents_md: true,
201
+ tools: nil,
202
+ max_steps: Riffer::Rig::Runtime::DEFAULT_MAX_STEPS
203
+ )
204
+ store = @store
205
+ return nil unless store
206
+
207
+ entries = store.read(id)
208
+ return nil if entries.empty?
209
+
210
+ runtime(model:, extensions:, skills:, agents_md:, tools:, max_steps:, snapshot: snapshot_of(entries, id))
211
+ end
212
+
213
+ # @rbs all: bool
214
+ # @rbs return: Array[::Riffer::Rig::Stores::Header]
215
+ def list(all: false)
216
+ store = @store
217
+ return [] unless store
218
+
219
+ all ? store.list : store.list(cwd: @cwd)
220
+ end
221
+
222
+ # @rbs id: String
223
+ # @rbs return: void
224
+ def delete(id)
225
+ store = @store
226
+ return unless store
227
+
228
+ store.delete(id)
229
+ end
230
+
140
231
  # Without force, a no-op while the tracked file set and the two rig.rb paths
141
- # look as the last discovery left them — the entry point the automatic
142
- # trigger (#133) calls. The snapshot advances on every attempt, failed ones
143
- # included, so a failure does not retry every request. Failures are notified
144
- # once and reported as the message, the way Settings.rejection reports a
145
- # rejection; the Runtime is the success value.
232
+ # look as the last discovery left them and the reload mode is not manual —
233
+ # the entry point the automatic trigger (#133) calls. The snapshot advances
234
+ # on every attempt, failed ones included, so a failure does not retry every
235
+ # request. Failures are notified once and reported as the message, the way
236
+ # Settings.rejection reports a rejection; the Runtime is the success value.
146
237
  # @rbs runtime: Riffer::Rig::Runtime
147
238
  # @rbs force: bool
148
239
  # @rbs return: (Riffer::Rig::Runtime | String)
149
240
  def reload(runtime, force: false)
150
241
  return runtime if !force && file_state == @file_state
242
+ return runtime if !force && manual_reload?
151
243
 
152
244
  outcome = perform_reload(runtime)
153
245
  @file_state = file_state
@@ -156,6 +248,27 @@ class Riffer::Rig::Loader
156
248
 
157
249
  private
158
250
 
251
+ # The reload mode a build runs under: the keyword wins over the setting,
252
+ # the setting over the default.
253
+ # @rbs document: Riffer::Rig::Settings::Document
254
+ # @rbs return: (:auto | :manual)
255
+ def reload_mode(document)
256
+ @reload_mode || document.reload
257
+ end
258
+
259
+ # @rbs return: bool
260
+ def manual_reload?
261
+ reload_mode(Riffer::Rig::Settings::Document.new(merged_settings)) == :manual
262
+ end
263
+
264
+ # The automatic trigger: runs at every before_request boundary, so the
265
+ # change check decides and a no-op costs the stat alone.
266
+ # @rbs runtime: Riffer::Rig::Runtime
267
+ # @rbs return: void
268
+ def automatic_reload(runtime)
269
+ _ = reload(runtime)
270
+ end
271
+
159
272
  # @rbs return: Hash[Symbol, untyped]
160
273
  def merged_settings
161
274
  Riffer::Rig::Settings.merge(
@@ -164,28 +277,24 @@ class Riffer::Rig::Loader
164
277
  )
165
278
  end
166
279
 
167
- # Notifies every failure once and reports its message; a rebuilt Runtime is
168
- # the success value.
280
+ # Notifies every failure once and reports its message, unexpected raises
281
+ # included — the automatic check runs mid-turn with no other isolation, and a
282
+ # raised attempt must still advance the snapshot in reload. A rebuilt Runtime
283
+ # is the success value.
169
284
  # @rbs runtime: Riffer::Rig::Runtime
170
285
  # @rbs return: (Riffer::Rig::Runtime | String)
171
286
  def perform_reload(runtime)
172
287
  settings = merged_settings
173
288
  document = Riffer::Rig::Settings::Document.new(settings)
174
- begin
175
- extensions = reload_extensions(document, settings)
176
- rescue AbandonedError => e
177
- return notify_failure(e.message)
178
- end
289
+ extensions = reload_extensions(document, settings)
179
290
 
180
291
  failure = reload_credentials(runtime)
181
292
  return notify_failure(failure) if failure
182
293
 
183
- begin
184
- runtime.rebuild(extensions: validated(extensions, settings), settings: settings)
185
- rescue StandardError => e
186
- return notify_failure(e.message)
187
- end
294
+ runtime.rebuild(extensions: validated(extensions, settings), settings: settings)
188
295
  runtime
296
+ rescue StandardError => e
297
+ notify_failure(e.message)
189
298
  end
190
299
 
191
300
  # @rbs message: String
@@ -453,15 +562,28 @@ class Riffer::Rig::Loader
453
562
  File.join(@home, '.riffer', 'settings.json')
454
563
  end
455
564
 
565
+ # @rbs entries: Array[Riffer::Rig::Stores::entry]
566
+ # @rbs id: String
567
+ # @rbs return: Hash[Symbol, untyped]
568
+ def snapshot_of(entries, id)
569
+ {
570
+ id: id,
571
+ messages: entries.filter_map { |entry| entry.message if entry.is_a?(Riffer::Rig::Stores::MessageEntry) },
572
+ model: entries.filter_map { |entry| entry.model if entry.is_a?(Riffer::Rig::Stores::ModelEntry) }.last,
573
+ skills: entries.filter_map { |entry| entry.skill if entry.is_a?(Riffer::Rig::Stores::SkillEntry) }
574
+ }
575
+ end
576
+
456
577
  # The store records unless it was declined with store: nil or globally with
457
578
  # "sessions": {"save": false}.
458
579
  # @rbs document: Riffer::Rig::Settings::Document
580
+ # @rbs resumed: bool
459
581
  # @rbs return: Riffer::Rig::Stores::Recorder?
460
- def recorder_for(document)
582
+ def recorder_for(document, resumed:)
461
583
  store = @store
462
584
  return nil unless document.save
463
585
  return nil unless store
464
586
 
465
- Riffer::Rig::Stores::Recorder.new(store: store)
587
+ Riffer::Rig::Stores::Recorder.new(store: store, resumed: resumed)
466
588
  end
467
589
  end
@@ -66,6 +66,7 @@ class Riffer::Rig::Runtime
66
66
  # @rbs @mcp_servers: Hash[String, Riffer::Rig::Mcp::Server]
67
67
  # @rbs @model_options: Hash[Symbol, untyped]
68
68
  # @rbs @native_tools: Hash[Symbol, untyped]
69
+ # @rbs @reload_check: (^(::Riffer::Rig::Runtime) -> void)?
69
70
 
70
71
  # @dynamic agent, credentials, cwd, host, id, settings, declared_settings
71
72
  attr_reader :agent #: Riffer::Agent
@@ -122,6 +123,7 @@ class Riffer::Rig::Runtime
122
123
  @claim_depth = 0
123
124
  @claim_thread = nil
124
125
  @core_commands = []
126
+ @reload_check = nil
125
127
  @cancel_flag = Riffer::Rig::Runtime::CancelFlag.new
126
128
  @session_start_pending = true
127
129
  @session_start_reason = snapshot ? :restore : :new
@@ -187,6 +189,16 @@ class Riffer::Rig::Runtime
187
189
  nil
188
190
  end
189
191
 
192
+ # Installs a host-of-the-runtime check (the Loader's automatic reload) so it
193
+ # survives every rebuild. The Runtime calls it at both before_request
194
+ # boundaries, before any handler runs, and ignores the result.
195
+ # @rbs check: ^(::Riffer::Rig::Runtime) -> void
196
+ # @rbs return: nil
197
+ def install_reload_check(check)
198
+ @reload_check = check
199
+ nil
200
+ end
201
+
190
202
  # @rbs name: String
191
203
  # @rbs args: String
192
204
  # @rbs &block: ?(::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event) -> void
@@ -448,6 +460,7 @@ class Riffer::Rig::Runtime
448
460
  # @rbs return: Enumerator[Riffer::StreamEvents::Base, Riffer::Agent::Response]
449
461
  def start_turn(text)
450
462
  @cancel_flag.clear
463
+ @reload_check&.call(self)
451
464
  refresh_system_message
452
465
  prompt = @hooks.before_prompt(text)
453
466
  return blocked_turn(prompt.reason) if prompt.is_a?(Riffer::Rig::Runtime::Blocked)
@@ -554,6 +567,7 @@ class Riffer::Rig::Runtime
554
567
 
555
568
  # @rbs return: void
556
569
  def before_next_request
570
+ @reload_check&.call(self)
557
571
  messages = @agent.session.messages
558
572
  # Upstream candidate: riffer has no hook between tool results and the next
559
573
  # request, so the last tool result's on_message stands in for one.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Riffer::Rig::Settings::Document
4
- # @dynamic model, reasoning, models, providers, disabled, autoload, native_tools, save
4
+ # @dynamic model, reasoning, models, providers, disabled, autoload, native_tools, save, reload
5
5
  attr_reader :model #: String?
6
6
  attr_reader :reasoning #: String?
7
7
  attr_reader :models #: Hash[String, Riffer::Rig::Settings::Pricing]
@@ -10,6 +10,7 @@ class Riffer::Rig::Settings::Document
10
10
  attr_reader :autoload #: bool
11
11
  attr_reader :native_tools #: Hash[Symbol, bool | Hash[Symbol, untyped]]
12
12
  attr_reader :save #: bool
13
+ attr_reader :reload #: (:auto | :manual)
13
14
 
14
15
  # @rbs source: Hash[Symbol, untyped]
15
16
  # @rbs return: void
@@ -31,6 +32,7 @@ class Riffer::Rig::Settings::Document
31
32
  @disabled = names.freeze
32
33
  @autoload = hash_or_empty(source[:extensions])[:autoload] == true
33
34
  @save = hash_or_empty(source[:sessions])[:save] != false
35
+ @reload = source[:reload] == 'manual' ? :manual : :auto
34
36
  @native_tools = hash_or_empty(hash_or_empty(source[:tools])[:native])
35
37
  .select { |_tool, switch| switch == true || switch.is_a?(Hash) }
36
38
  .transform_keys(&:to_sym).freeze
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Stores::Header
4
+ TYPE = 'header' #: String
5
+
6
+ include Riffer::Rig::Support::Equatable
7
+
8
+ # @rbs @schema_version: Integer
9
+ # @rbs @id: String
10
+ # @rbs @cwd: String
11
+ # @rbs @created_at: String
12
+ # @rbs @model: String
13
+ # @rbs @riffer_rig_version: String
14
+ # @rbs @riffer_version: String
15
+ # @rbs @title: String
16
+ # @rbs @updated: Time?
17
+
18
+ # @dynamic schema_version, id, cwd, created_at, model, riffer_rig_version, riffer_version, title, updated
19
+ attr_reader :schema_version #: Integer
20
+ attr_reader :id #: String
21
+ attr_reader :cwd #: String
22
+ attr_reader :created_at #: String
23
+ attr_reader :model #: String
24
+ attr_reader :riffer_rig_version #: String
25
+ attr_reader :riffer_version #: String
26
+ attr_reader :title #: String
27
+ # Store-derived metadata, not entry data: stamped by the store while it
28
+ # holds the physical artifact (the JSONL file's mtime), so it is excluded
29
+ # from to_h.
30
+ attr_reader :updated #: Time?
31
+
32
+ # @rbs hash: Hash[Symbol, untyped]
33
+ # @rbs updated: Time?
34
+ # @rbs return: ::Riffer::Rig::Stores::Header?
35
+ def self.from_hash(hash, updated: nil)
36
+ schema_version = hash[:schema_version]
37
+ id = hash[:id]
38
+ cwd = hash[:cwd]
39
+ created_at = hash[:created_at]
40
+ model = hash[:model]
41
+ riffer_rig_version = hash[:riffer_rig_version]
42
+ riffer_version = hash[:riffer_version]
43
+ title = hash[:title]
44
+ return nil unless schema_version.is_a?(Integer)
45
+ return nil unless [id, cwd, created_at, model, riffer_rig_version, riffer_version, title].all?(String)
46
+
47
+ new(
48
+ schema_version: schema_version,
49
+ id: id,
50
+ cwd: cwd,
51
+ created_at: created_at,
52
+ model: model,
53
+ riffer_rig_version: riffer_rig_version,
54
+ riffer_version: riffer_version,
55
+ title: title,
56
+ updated: updated
57
+ )
58
+ end
59
+
60
+ # @rbs schema_version: Integer
61
+ # @rbs id: String
62
+ # @rbs cwd: String
63
+ # @rbs created_at: String
64
+ # @rbs model: String
65
+ # @rbs riffer_rig_version: String
66
+ # @rbs riffer_version: String
67
+ # @rbs title: String
68
+ # @rbs updated: Time?
69
+ # @rbs return: void
70
+ def initialize(
71
+ schema_version:,
72
+ id:,
73
+ cwd:,
74
+ created_at:,
75
+ model:,
76
+ riffer_rig_version:,
77
+ riffer_version:,
78
+ title:,
79
+ updated: nil
80
+ )
81
+ @schema_version = schema_version
82
+ @id = id
83
+ @cwd = cwd
84
+ @created_at = created_at
85
+ @model = model
86
+ @riffer_rig_version = riffer_rig_version
87
+ @riffer_version = riffer_version
88
+ @title = title
89
+ @updated = updated
90
+ freeze
91
+ end
92
+
93
+ # @rbs return: Hash[Symbol, untyped]
94
+ def to_h
95
+ {
96
+ type: TYPE,
97
+ schema_version: @schema_version,
98
+ id: @id,
99
+ cwd: @cwd,
100
+ created_at: @created_at,
101
+ model: @model,
102
+ riffer_rig_version: @riffer_rig_version,
103
+ riffer_version: @riffer_version,
104
+ title: @title
105
+ }
106
+ end
107
+ end
@@ -6,6 +6,13 @@ require 'json'
6
6
  class Riffer::Rig::Stores::JSONL
7
7
  HEADER_VERSION = 1 #: Integer
8
8
 
9
+ ENTRY_TYPES = {
10
+ Riffer::Rig::Stores::Header::TYPE => Riffer::Rig::Stores::Header,
11
+ Riffer::Rig::Stores::MessageEntry::TYPE => Riffer::Rig::Stores::MessageEntry,
12
+ Riffer::Rig::Stores::ModelEntry::TYPE => Riffer::Rig::Stores::ModelEntry,
13
+ Riffer::Rig::Stores::SkillEntry::TYPE => Riffer::Rig::Stores::SkillEntry
14
+ }.freeze #: Hash[String, Riffer::Rig::Stores::entry_type]
15
+
9
16
  # @rbs @home: String
10
17
  # @rbs @paths: Hash[String, String]
11
18
 
@@ -17,26 +24,31 @@ class Riffer::Rig::Stores::JSONL
17
24
  end
18
25
 
19
26
  # @rbs id: String
20
- # @rbs entry: Hash[Symbol, untyped]
27
+ # @rbs entry: Riffer::Rig::Stores::entry
21
28
  # @rbs return: void
22
29
  def append(id, entry)
23
- File.write(path_for(id, entry), "#{JSON.generate(entry)}\n", mode: 'a')
30
+ File.write(path_for(id, entry), "#{JSON.generate(entry.to_h)}\n", mode: 'a')
24
31
  end
25
32
 
26
33
  # @rbs id: String
27
- # @rbs return: Array[Hash[Symbol, untyped]]
34
+ # @rbs return: Array[Riffer::Rig::Stores::entry]
28
35
  def read(id)
29
36
  path = find(id)
30
37
  return [] unless path
31
38
 
32
- File.foreach(path).filter_map { |line| parse(line) }
39
+ File.foreach(path).filter_map { |line| entry_of(line) }
33
40
  end
34
41
 
35
42
  # @rbs cwd: String?
36
- # @rbs return: Array[Hash[Symbol, untyped]]
43
+ # @rbs return: Array[::Riffer::Rig::Stores::Header]
37
44
  def list(cwd: nil)
38
- headers = Dir.glob(File.join(root, '*', '*.jsonl')).filter_map { |path| header_of(path) }
39
- cwd ? headers.select { |header| header[:cwd] == cwd } : headers
45
+ headers = Dir.glob(File.join(root, '*', '*.jsonl')).filter_map do |path|
46
+ # Seeding the path cache here keeps picking the most recently updated
47
+ # session one mtime per header instead of one glob.
48
+ @paths[File.basename(path, '.jsonl')] = path
49
+ header_of(path)
50
+ end
51
+ cwd ? headers.select { |header| header.cwd == cwd } : headers
40
52
  end
41
53
 
42
54
  # @rbs id: String
@@ -57,11 +69,11 @@ class Riffer::Rig::Stores::JSONL
57
69
  end
58
70
 
59
71
  # @rbs id: String
60
- # @rbs entry: Hash[Symbol, untyped]
72
+ # @rbs entry: Riffer::Rig::Stores::entry
61
73
  # @rbs return: String
62
74
  def path_for(id, entry)
63
75
  @paths.fetch(id) do
64
- path = entry[:type] == 'header' ? new_path(id, entry.fetch(:cwd)) : find(id)
76
+ path = entry.is_a?(Riffer::Rig::Stores::Header) ? new_path(id, entry.cwd) : find(id)
65
77
  raise ArgumentError, "no session file for #{id}" unless path
66
78
 
67
79
  @paths[id] = path
@@ -77,14 +89,6 @@ class Riffer::Rig::Stores::JSONL
77
89
  File.join(dir, "#{id}.jsonl")
78
90
  end
79
91
 
80
- # @rbs path: String
81
- # @rbs return: Hash[Symbol, untyped]?
82
- def header_of(path)
83
- line = File.foreach(path).first
84
- entry = parse(line) if line
85
- entry if entry.is_a?(Hash) && entry[:type] == 'header'
86
- end
87
-
88
92
  # @rbs id: String
89
93
  # @rbs return: String?
90
94
  def find(id)
@@ -105,6 +109,28 @@ class Riffer::Rig::Stores::JSONL
105
109
  slug.empty? || slug.match?(/\A\.{1,2}\z/) ? '-' : slug
106
110
  end
107
111
 
112
+ # A session's last update is its file's mtime: appends are the only writes.
113
+ # The store stamps it while it holds the artifact, so the listed header
114
+ # carries it.
115
+ # @rbs path: String
116
+ # @rbs return: ::Riffer::Rig::Stores::Header?
117
+ def header_of(path)
118
+ line = File.foreach(path).first
119
+ hash = parse(line) if line
120
+ return nil unless hash && hash[:type] == Riffer::Rig::Stores::Header::TYPE
121
+
122
+ Riffer::Rig::Stores::Header.from_hash(hash, updated: File.mtime(path))
123
+ end
124
+
125
+ # @rbs line: String
126
+ # @rbs return: Riffer::Rig::Stores::entry?
127
+ def entry_of(line)
128
+ hash = parse(line)
129
+ return nil unless hash
130
+
131
+ ENTRY_TYPES[hash[:type]]&.from_hash(hash)
132
+ end
133
+
108
134
  # @rbs line: String
109
135
  # @rbs return: Hash[Symbol, untyped]?
110
136
  def parse(line)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Stores::MessageEntry
4
+ TYPE = 'message' #: String
5
+
6
+ include Riffer::Rig::Support::Equatable
7
+
8
+ # @dynamic message
9
+ attr_reader :message #: Hash[Symbol, untyped]
10
+
11
+ # @rbs hash: Hash[Symbol, untyped]
12
+ # @rbs return: ::Riffer::Rig::Stores::MessageEntry?
13
+ def self.from_hash(hash)
14
+ message = hash[:message]
15
+ return nil unless message.is_a?(Hash)
16
+
17
+ new(message: message)
18
+ end
19
+
20
+ # @rbs message: Hash[Symbol, untyped]
21
+ # @rbs return: void
22
+ def initialize(message:)
23
+ @message = message
24
+ freeze
25
+ end
26
+
27
+ # @rbs return: Hash[Symbol, untyped]
28
+ def to_h
29
+ { type: TYPE, message: @message }
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Stores::ModelEntry
4
+ TYPE = 'model' #: String
5
+
6
+ include Riffer::Rig::Support::Equatable
7
+
8
+ # @dynamic model
9
+ attr_reader :model #: String
10
+
11
+ # @rbs hash: Hash[Symbol, untyped]
12
+ # @rbs return: ::Riffer::Rig::Stores::ModelEntry?
13
+ def self.from_hash(hash)
14
+ model = hash[:model]
15
+ return nil unless model.is_a?(String)
16
+
17
+ new(model: model)
18
+ end
19
+
20
+ # @rbs model: String
21
+ # @rbs return: void
22
+ def initialize(model:)
23
+ @model = model
24
+ freeze
25
+ end
26
+
27
+ # @rbs return: Hash[Symbol, untyped]
28
+ def to_h
29
+ { type: TYPE, model: @model }
30
+ end
31
+ end