terret-core 0.1.0 → 0.1.1

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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ # ctx[:redactor] — §13's "credentials never enter the session log", in the
5
+ # two layers docs/exec.md §6 describes.
6
+ #
7
+ # The FIRST is a tools/post_execute listener: the waterfall every tool
8
+ # result already passes through, so a tool that happened to hand back a
9
+ # credential is rewritten before the loop appends it — and before any other
10
+ # post_execute listener, or any in-memory consumer of the Result, reads it.
11
+ #
12
+ # The SECOND is a Sessions scrubber, and it is the one that makes the
13
+ # promise true rather than likely: a secret can reach the log through a path
14
+ # that never touches a tool result at all — a user's own message, an
15
+ # injected steer, a plugin's durable event — and register_scrubber runs over
16
+ # every String of every append regardless of type. Because that runs INSIDE
17
+ # normalize_payload, the stored bytes and every projection derived from them
18
+ # agree by construction, so the log invariant needs nothing special here.
19
+ #
20
+ # What this is not: comprehensive. Patterns are regexp sources on this row's
21
+ # config, so it catches shapes a deployment named and nothing else. Catching a
22
+ # secret by its exact bytes rather than by a named shape is ctx[:credentials]'s
23
+ # job (plan §6.9, Terret::Credentials): it registers every value it resolves
24
+ # as its own append-boundary scrubber, running alongside this one.
25
+ class Redactor < Hames::Service
26
+ service_key :redactor
27
+ inject :sessions
28
+ config_schema patterns: { type: Array, default: [],
29
+ doc: "regexp/string patterns scrubbed from tool output and the log" },
30
+ replacement: { type: String, default: "[REDACTED]",
31
+ doc: "text a matched secret is replaced with" }
32
+
33
+ DEFAULT_REPLACEMENT = "[REDACTED]"
34
+
35
+ def start(ctx)
36
+ @patterns = compile(config[:patterns])
37
+
38
+ ctx.on("tools/post_execute") { |result, next_| next_.(redact_result(result)) }
39
+ # Registered through the seam rather than as a second listener: this
40
+ # runs at the append boundary itself (Sessions#register_scrubber), which
41
+ # is the whole reason the backstop is trustworthy.
42
+ ctx[:sessions].register_scrubber(method(:redact))
43
+ end
44
+
45
+ # Patterns are compiled, so they are the one thing a hot swap has to
46
+ # re-derive; the replacement token is read per call and is already live.
47
+ # An uncompilable pattern raises here, and the loader rolls the row back.
48
+ def reconfigure(config)
49
+ @patterns = compile(config[:patterns])
50
+ end
51
+
52
+ # The redaction itself, and the method the Sessions scrubber calls. A
53
+ # non-String is handed straight back: this is reached from the tools
54
+ # pipeline too, where a result's content may be anything a handler
55
+ # returned.
56
+ def redact(text)
57
+ return text unless text.is_a?(String)
58
+
59
+ # gsub's BLOCK form, not its string form, which would read `\0`/`\1` in
60
+ # a deployment's replacement token as backreferences — a token
61
+ # containing `\0` would paste the matched secret back in and leave a
62
+ # redactor that silently un-redacts.
63
+ @patterns.reduce(text) { |acc, pattern| acc.gsub(pattern) { replacement } }
64
+ end
65
+
66
+ # Public because a reader downstream has to recognize this mechanism's own
67
+ # mark: the loop refuses to replay a resumed tool call whose stored
68
+ # arguments carry it (Loop#redaction_token).
69
+ def replacement = config[:replacement] || DEFAULT_REPLACEMENT
70
+
71
+ private
72
+
73
+ # Both fields, because both are model-visible: derive_messages projects a
74
+ # tool/result's error alongside its content, so a handler that echoes the
75
+ # credential it was handed into a Failure message leaks through exactly
76
+ # the same door. The append scrubber would catch either, but this layer
77
+ # exists so the Result object itself is clean the moment it leaves the
78
+ # pipeline.
79
+ def redact_result(result)
80
+ return result unless result.is_a?(Tools::Result)
81
+
82
+ result.with(content: redact(result.content), error: redact(result.error))
83
+ end
84
+
85
+ # A pattern that does not compile is a configuration bug, so it fails at
86
+ # boot (or at the reconfigure that introduced it) rather than raising on
87
+ # every append forever after. A Regexp is accepted as itself, so a plugin
88
+ # composing rows in Ruby need not stringify what it already has.
89
+ def compile(patterns)
90
+ Array(patterns).map do |pattern|
91
+ next pattern if pattern.is_a?(Regexp)
92
+
93
+ begin
94
+ Regexp.new(pattern.to_s)
95
+ rescue RegexpError => e
96
+ raise RegexpError, "redactor pattern #{pattern.inspect} does not compile: #{e.message}"
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -8,6 +8,12 @@ module Terret
8
8
  SessionEvent = Data.define(:id, :session_id, :seq, :at, :type, :payload)
9
9
 
10
10
  class LogInvariantViolation < StandardError; end
11
+ class NonPrimitivePayload < StandardError; end
12
+ # A registered scrubber broke its contract. Distinct from
13
+ # NonPrimitivePayload on purpose: that one means the DATA was unstorable
14
+ # (something a caller can fix by encoding it), this one means a PLUGIN is
15
+ # broken, and the two must not be rescued by the same handler.
16
+ class ScrubberContractViolation < StandardError; end
11
17
 
12
18
  # ctx.sessions — the append-only session log. The single source of the
13
19
  # context the model sees: derive_messages projects model history from it,
@@ -15,36 +21,114 @@ module Terret
15
21
  # exactly what replaying the log yields ("model-visible means logged").
16
22
  class Sessions < Hames::Service
17
23
  service_key :sessions
24
+ inject :session_store
25
+ config_schema({}) # the session log takes no config
18
26
 
19
27
  Session = Struct.new(:id, :events, :parent_id, keyword_init: true)
20
28
 
29
+ # Payload keys whose values the harness minted to point at something else:
30
+ # tool call ids and their approval foreign keys, the part tag decode_part
31
+ # dispatches on, lineage, verdicts, the live allow list.
32
+ # A pattern written for a credential — long hex, a UUID shape — matches
33
+ # these too, and rewriting one protects nothing (none of it is
34
+ # model-carried) while it can wreck the log for good: two tool calls that
35
+ # collapse to one id are a request providers reject, a mangled tag makes
36
+ # the session undecodable, and a rewritten pattern list silently changes
37
+ # what an agent may run. Add a key here only when the harness itself
38
+ # generates its value. A tool NAME is deliberately absent: the model
39
+ # chooses it, so it is content — and redacting one fails safe, because a
40
+ # name that stops resolving comes back as a not-found error rather than
41
+ # collapsing two calls onto one identifier.
42
+ STRUCTURAL_KEYS = %i[id call_id type verdict status agent parent_id
43
+ from boundary upto_seq n patterns].freeze
44
+
45
+ # Keys whose value is a structural CONTAINER rather than a leaf: the
46
+ # exemption reaches through them to the identifiers inside, because an
47
+ # assistant message's encoded parts each carry their own tag and id.
48
+ STRUCTURAL_CONTAINERS = %i[parts].freeze
49
+
21
50
  def start(ctx)
22
51
  @ctx = ctx
23
- @store = {}
24
- @dir = config[:jsonl_dir] # optional persistence
52
+ @store = ctx[:session_store]
53
+ @cache = {}
54
+ @locks = {}
55
+ @locks_mutex = Mutex.new
56
+ @emit_mutex = Mutex.new
57
+ @emit_queue = []
58
+ @emitting = false
59
+ @scrubbers = []
60
+ end
61
+
62
+ # Rewrite every String of every durable payload on its way into the log
63
+ # (§13's log boundary; docs/exec.md §6). Registration is an effect, so the
64
+ # returned disposer unregisters and unloading the owning plugin reaps it.
65
+ #
66
+ # This is the boundary the scrubbing has to happen at rather than anywhere
67
+ # downstream: the stored event and every projection derived from it —
68
+ # derive_messages, and so both sides of the digest assert_log_invariant!
69
+ # compares — read the same already-scrubbed bytes, so "model-visible means
70
+ # logged" holds by construction. A read-time filter over the projection
71
+ # would leave the secret in the log itself and split the digest in two.
72
+ #
73
+ # Scrubbers fold in registration order: each is handed the previous one's
74
+ # output, so a later one can rewrite what an earlier one produced.
75
+ # What a live in-memory value would look like once stored, for a caller
76
+ # that has to compare one against a payload already in the log — the
77
+ # approvals gate matches a recorded verdict on the call's args, and
78
+ # scrubbing rewrites one side of that comparison. CONTENT scope, because
79
+ # such a value always sits nested under a content key (`args`), so its own
80
+ # keys get no structural exemption.
81
+ def stored_form(value) = normalize_payload(value, structural: false)
82
+
83
+ # Whether anything is registered. Callers that must reshape what they
84
+ # append to give a scrubber a fair look at it — the loop's chunk carry —
85
+ # ask this so they can stay exactly as they were when nobody is scrubbing.
86
+ def scrubbing? = !@scrubbers.empty?
87
+
88
+ # `ctx:` decides the registration's LIFETIME, not its reach: the scrubber
89
+ # list is the service's, so a scrubber always sees every append, but a
90
+ # caller passing its forked agent context ties ownership to that fork —
91
+ # the same bleed Registry#register closed, where a registration made by an
92
+ # agent outlived the agent that made it.
93
+ def register_scrubber(callable, ctx: @ctx)
94
+ ctx.effect do
95
+ @scrubbers << callable
96
+ # Identity, not ==: removing "the entry equal to this callable" would
97
+ # take a twin down with it if the same object were registered twice,
98
+ # and a scrubber that defines its own == could unregister a stranger.
99
+ lambda do
100
+ i = @scrubbers.rindex { |s| s.equal?(callable) }
101
+ @scrubbers.delete_at(i) if i
102
+ end
103
+ end
25
104
  end
26
105
 
27
106
  def create(id: SecureRandom.hex(6), parent_id: nil)
28
107
  s = Session.new(id:, events: [], parent_id:)
29
- @store[id] = s
108
+ @cache[id] = s
30
109
  append(id, "session/created", { parent_id: })
31
110
  s
32
111
  end
33
112
 
34
- def fetch(id) = @store.fetch(id)
113
+ def fetch(id) = @cache.fetch(id)
35
114
 
36
115
  def append(session_id, type, payload = {})
37
116
  decl = Hames.event(type)
38
117
  raise Hames::ContractError, "#{type} is not a durable event" unless decl.durable
39
118
 
40
119
  s = fetch(session_id)
41
- ev = SessionEvent.new(
42
- id: SecureRandom.hex(8), session_id:, seq: s.events.length,
43
- at: Time.now.utc, type: type.to_s, payload: payload
44
- )
45
- s.events << ev
46
- persist(ev)
47
- @ctx.emit("session/event", ev)
120
+ normalized = normalize_payload(payload)
121
+ ev = lock_for(session_id).synchronize do
122
+ e = SessionEvent.new(
123
+ id: SecureRandom.hex(8), session_id:, seq: s.events.length,
124
+ at: Time.now.utc, type: type.to_s, payload: normalized
125
+ )
126
+ # durable first: if the store raises, nothing believes the event happened
127
+ @store.append(e)
128
+ s.events << e
129
+ e
130
+ end
131
+ fan_out(ev)
48
132
  ev
49
133
  end
50
134
 
@@ -52,12 +136,15 @@ module Terret
52
136
  def derive_messages(session_id, upto: nil)
53
137
  events = fetch(session_id).events
54
138
  events = events.take(upto) if upto
55
- events.filter_map do |ev|
139
+ apply_compaction(events).filter_map do |ev|
56
140
  case ev.type
57
141
  when "user/message", "context/injected"
58
142
  LLM::Message.new(role: :user, parts: [LLM::Text.new(text: ev.payload[:text])])
143
+ when "session/compacted"
144
+ LLM::Message.new(role: :user, parts: [LLM::Text.new(text: ev.payload[:summary])])
59
145
  when "assistant/message"
60
- LLM::Message.new(role: :assistant, parts: ev.payload[:parts])
146
+ LLM::Message.new(role: :assistant,
147
+ parts: ev.payload[:parts].map { |p| LLM.decode_part(p) })
61
148
  when "tool/result"
62
149
  LLM::Message.new(role: :tool, parts: [
63
150
  LLM::ToolResult.new(id: ev.payload[:id], content: ev.payload[:content],
@@ -67,6 +154,30 @@ module Terret
67
154
  end
68
155
  end
69
156
 
157
+ # Lifetime spend, projected from the log: sums every step/end's usage.
158
+ # A step whose provider sent no usage still counts as a step; its costs
159
+ # count as zero rather than poisoning the sum.
160
+ def usage(session_id)
161
+ out = { prompt_tokens: 0, completion_tokens: 0, cost: 0.0, steps: 0 }
162
+ fetch(session_id).events.each do |ev|
163
+ next unless ev.type == "step/end"
164
+
165
+ out[:steps] += 1
166
+ u = ev.payload[:usage] or next
167
+ out[:prompt_tokens] += u[:prompt_tokens] || 0
168
+ out[:completion_tokens] += u[:completion_tokens] || 0
169
+ out[:cost] += u[:cost] || 0.0
170
+ end
171
+ out
172
+ end
173
+
174
+ # The latest session/titled's title, or nil. Metadata, not model history.
175
+ def title(session_id)
176
+ fetch(session_id).events.reverse_each
177
+ .find { |e| e.type == "session/titled" }
178
+ &.payload&.[](:title)
179
+ end
180
+
70
181
  # The enforcement point for "model-visible means logged": the loop calls
71
182
  # this with the message list it is about to send; a mismatch against the
72
183
  # log projection raises in dev/test.
@@ -78,29 +189,221 @@ module Terret
78
189
  "outbound request diverges from session log projection for #{session_id}"
79
190
  end
80
191
 
192
+ # Forks the in-memory working set; resume a store-only session first.
81
193
  def fork(source_id, boundary: nil, child_id: SecureRandom.hex(6))
82
194
  src = fetch(source_id)
83
195
  child = Session.new(id: child_id, events: [], parent_id: source_id)
84
- @store[child_id] = child
196
+ @cache[child_id] = child
85
197
  events = boundary ? src.events.take(boundary) : src.events.dup
86
- events.each { |ev| child.events << ev.with(session_id: child_id) }
198
+ events.each do |ev|
199
+ copy = ev.with(session_id: child_id)
200
+ @store.append(copy)
201
+ child.events << copy
202
+ end
87
203
  append(child_id, "session/forked", { from: source_id, boundary: boundary })
88
204
  child
89
205
  end
90
206
 
207
+ def read(session_id, from_seq: 0) = @store.read(session_id, from_seq: from_seq)
208
+
209
+ def session_ids = @store.session_ids
210
+
211
+ # Rebuild a session's working set from the durable store. Idempotent: a
212
+ # session already in memory is returned as-is (write-through keeps the
213
+ # store equal). New appends continue after the last recorded seq.
214
+ def resume(session_id)
215
+ return @cache[session_id] if @cache.key?(session_id)
216
+
217
+ events = @store.read(session_id)
218
+ raise KeyError, "unknown session #{session_id}" if events.empty?
219
+
220
+ @cache[session_id] = Session.new(id: session_id, events: events,
221
+ parent_id: parent_id_from(events))
222
+ end
223
+
91
224
  private
92
225
 
93
- def digest(messages)
94
- Digest::SHA256.hexdigest(messages.map(&:inspect).join("\x1e"))
226
+ # Seq assignment, the durable write, and the memory push are one critical
227
+ # section per session. The store write is a yield point — JSONL opens a
228
+ # file, and a fiber scheduler switches there — so without this two
229
+ # appenders (a connection frame and a turn, say) read the same
230
+ # events.length and both claim it. Mutex is fiber-aware under the
231
+ # scheduler, so this parks a fiber rather than stalling the reactor.
232
+ def lock_for(session_id)
233
+ @locks_mutex.synchronize { @locks[session_id] ||= Mutex.new }
95
234
  end
96
235
 
97
- def persist(ev)
98
- return unless @dir
236
+ # Fan-out runs OUTSIDE the append lock, and in seq order. A listener that
237
+ # appends while handling an event — the compactor and the titler both do,
238
+ # on turn/end — would otherwise deliver its nested event to every other
239
+ # subscriber before the event it reacted to, so a socket tail would see
240
+ # the log out of order. Queueing behind the delivery in flight fixes that,
241
+ # at a price worth naming: a listener's own append returns before that
242
+ # event fans out. Assumes one reactor; the flag is mutex-guarded so
243
+ # threaded appenders cannot lose a queued event between the two.
244
+ def fan_out(ev)
245
+ @emit_mutex.synchronize do
246
+ @emit_queue << ev
247
+ return if @emitting
99
248
 
100
- File.open(File.join(@dir, "#{ev.session_id}.jsonl"), "a") do |f|
101
- f.puts JSON.generate(type: ev.type, seq: ev.seq, at: ev.at.iso8601,
102
- payload: ev.payload.inspect)
249
+ @emitting = true
103
250
  end
251
+ drain_emits
252
+ end
253
+
254
+ def drain_emits
255
+ while (ev = next_emit)
256
+ @ctx.emit("session/event", ev)
257
+ end
258
+ rescue Exception
259
+ @emit_mutex.synchronize { @emitting = false }
260
+ raise
261
+ end
262
+
263
+ def next_emit
264
+ @emit_mutex.synchronize do
265
+ ev = @emit_queue.shift
266
+ @emitting = false unless ev
267
+ ev
268
+ end
269
+ end
270
+
271
+ # Compacted history is still model-visible, so it lives in the log and
272
+ # projects as a user message standing in for everything at or before its
273
+ # boundary. The latest compaction wins; superseded ones drop out.
274
+ def apply_compaction(events)
275
+ latest = nil
276
+ events.each { |ev| latest = ev if ev.type == "session/compacted" }
277
+ return events unless latest
278
+
279
+ survivors = events.select do |ev|
280
+ ev.seq > latest.payload[:upto_seq] && ev.type != "session/compacted"
281
+ end
282
+ [latest] + survivors
283
+ end
284
+
285
+ def parent_id_from(events)
286
+ forked = events.reverse.find { |e| e.type == "session/forked" }
287
+ forked ? forked.payload[:from] : events.first&.payload&.[](:parent_id)
288
+ end
289
+
290
+ # The primitives contract: durable payloads hold only strings, numbers,
291
+ # booleans, nil, arrays, and symbol-keyed hashes of the same. Symbols in
292
+ # value position become strings; string keys become symbols. Anything
293
+ # else raises — typed objects are encoded at the edges (LLM.encode_part).
294
+ #
295
+ # `scrub` and `structural` carry the redaction scope down the recursion:
296
+ # see STRUCTURAL_KEYS for what the exemption is and #child_scope for
297
+ # where it stops.
298
+ def normalize_payload(value, scrub: true, structural: true)
299
+ case value
300
+ when Integer, true, false, nil then value
301
+ when String
302
+ utf8 = begin
303
+ value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
304
+ rescue EncodingError
305
+ raise NonPrimitivePayload,
306
+ "#{value.encoding.name} string does not convert to UTF-8; scrub it first"
307
+ end
308
+ unless utf8.valid_encoding?
309
+ raise NonPrimitivePayload, "invalid UTF-8 string is not storable; scrub it first"
310
+ end
311
+
312
+ # After validation, so a scrubber is always handed storable UTF-8 —
313
+ # and re-validated below, because it hands something back.
314
+ scrub && !@scrubbers.empty? ? scrub_string(utf8) : utf8
315
+ when Float
316
+ raise NonPrimitivePayload, "non-finite Float is not storable" unless value.finite?
317
+
318
+ value
319
+ # Through the String arm rather than straight to the store: a symbol
320
+ # in value position is content like any other, and `to_s` on an
321
+ # ASCII-only symbol hands back a US-ASCII string that no scrubber
322
+ # would have seen and no encoding check would have normalized.
323
+ when Symbol then normalize_payload(value.to_s, scrub:, structural:)
324
+ when Array then value.map { |v| normalize_payload(v, scrub:, structural:) }
325
+ when Hash
326
+ value.each_with_object({}) do |(k, v), out|
327
+ key = case k
328
+ when Symbol then k
329
+ when String then k.to_sym
330
+ else raise NonPrimitivePayload, "#{k.class} is not a storable hash key"
331
+ end
332
+ # child_scope reads the un-scrubbed key: the structural exemption is
333
+ # decided by the real field NAME, before that name is ever rewritten.
334
+ child_scrub, child_structural = child_scope(key, scrub, structural)
335
+ # A KEY is content too once we have descended past the structural
336
+ # surface. An MCP tool's structured_content is a Hash whose keys the
337
+ # tool authored, and a secret-shaped one reached the log verbatim
338
+ # because only values recursed. Field names AT a structural level are
339
+ # the log's own vocabulary the projection reads by name, so they are
340
+ # never rewritten (`scrub_key` returns them untouched); the value
341
+ # exemption STRUCTURAL_KEYS carries is unaffected.
342
+ stored = scrub_key(key, scrub, structural)
343
+ raise NonPrimitivePayload, "duplicate key #{stored.inspect} after coercion" if out.key?(stored)
344
+
345
+ out[stored] = normalize_payload(v, scrub: child_scrub, structural: child_structural)
346
+ end
347
+ else
348
+ raise NonPrimitivePayload, "#{value.class} is not storable; encode it first"
349
+ end
350
+ end
351
+
352
+ # Where a key sits decides whether its value is exempt, because the same
353
+ # NAME means different things at different depths: `parts[0][:name]` is
354
+ # the tool a model called, while `args[:name]` is a terminal name the
355
+ # model itself wrote and `args[:content]` is a whole file. So the
356
+ # exemption holds only while nothing content-bearing has been entered:
357
+ # descending into anything outside these two lists turns it off for good,
358
+ # and everything below that point is scrubbed whatever it is called.
359
+ def child_scope(key, scrub, structural)
360
+ return [false, false] unless scrub # already inside an exempt subtree
361
+ return [false, false] if structural && STRUCTURAL_KEYS.include?(key)
362
+ return [true, true] if structural && STRUCTURAL_CONTAINERS.include?(key)
363
+
364
+ [true, false]
365
+ end
366
+
367
+ # A key scrubs exactly where its sibling leaves do: only in content scope
368
+ # (past the structural surface) and only when something is registered.
369
+ # Routed through the String arm rather than scrub_string directly, so the
370
+ # key is UTF-8-encoded and validated before a scrubber sees it — the same
371
+ # contract every value String already meets. With no scrubber registered
372
+ # the key is handed back untouched, so a session that never scrubs stores
373
+ # byte-identical keys to before.
374
+ def scrub_key(key, scrub, structural)
375
+ return key unless scrub && !structural && !@scrubbers.empty?
376
+
377
+ normalize_payload(key.to_s, scrub: true, structural: false).to_sym
378
+ end
379
+
380
+ # Fold a payload string through the registered scrubbers, checking each
381
+ # answer. A scrubber runs after the primitives contract has already
382
+ # admitted the value, so a bad answer would land in the store unexamined:
383
+ # a nil where text was, or bytes the store's JSON generator rejects a
384
+ # frame later, with nothing left to say which plugin did it. Model-reachable
385
+ # data must never crash the harness, but a broken PLUGIN may and should —
386
+ # so this raises, naming the offender (a Proc's inspect carries its
387
+ # source location) rather than repairing what it cannot guess at.
388
+ def scrub_string(text)
389
+ @scrubbers.reduce(text) do |acc, scrubber|
390
+ out = scrubber.call(acc)
391
+ unless out.is_a?(String)
392
+ raise ScrubberContractViolation,
393
+ "scrubber #{scrubber.inspect} returned #{out.class}, not a String"
394
+ end
395
+ unless out.encoding == Encoding::UTF_8 && out.valid_encoding?
396
+ raise ScrubberContractViolation,
397
+ "scrubber #{scrubber.inspect} returned #{out.encoding.name} that is not valid " \
398
+ "UTF-8; a scrubber is handed UTF-8 and must hand UTF-8 back"
399
+ end
400
+
401
+ out
402
+ end
403
+ end
404
+
405
+ def digest(messages)
406
+ Digest::SHA256.hexdigest(messages.map(&:inspect).join("\x1e"))
104
407
  end
105
408
  end
106
409
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+
7
+ module Terret
8
+ # ctx[:session_store] — the persistence seam behind ctx.sessions. Providers
9
+ # durably record SessionEvents (whose payloads are primitives by the append
10
+ # contract) and hand them back exactly. Swapping the provider is a config
11
+ # row edit; Sessions never knows which one it is talking to. start is
12
+ # idempotent on every provider so an instance can ride across reboots.
13
+ module Store
14
+ # In-memory provider: the test default. Events are immutable, so sharing
15
+ # objects with Sessions' working set costs nothing.
16
+ class Memory < Hames::Service
17
+ service_key :session_store
18
+ config_schema({}) # the test default takes no config
19
+
20
+ def start(_ctx)
21
+ @events ||= Hash.new { |h, k| h[k] = [] }
22
+ end
23
+
24
+ def append(event) = @events[event.session_id] << event
25
+
26
+ def read(session_id, from_seq: 0)
27
+ @events.fetch(session_id, []).select { |ev| ev.seq >= from_seq }
28
+ end
29
+
30
+ # Order is provider-defined; recency must be derived from event timestamps.
31
+ def session_ids = @events.keys
32
+ end
33
+
34
+ # JSONL provider: one file per session, one JSON envelope per line, made
35
+ # for grepping. at carries microseconds so Time round-trips exactly.
36
+ class JSONL < Hames::Service
37
+ service_key :session_store
38
+ config_schema dir: { type: String, required: true,
39
+ doc: "directory holding one JSONL file per session" }
40
+
41
+ def start(_ctx)
42
+ @dir ||= config.fetch(:dir).tap { |d| FileUtils.mkdir_p(d) }
43
+ end
44
+
45
+ def append(event)
46
+ File.open(path(event.session_id), "a") do |f|
47
+ f.puts JSON.generate(
48
+ id: event.id, session_id: event.session_id, seq: event.seq,
49
+ at: event.at.iso8601(6), type: event.type, payload: event.payload
50
+ )
51
+ end
52
+ end
53
+
54
+ def read(session_id, from_seq: 0)
55
+ return [] unless File.exist?(path(session_id))
56
+
57
+ File.foreach(path(session_id)).filter_map do |line|
58
+ h = begin
59
+ JSON.parse(line, symbolize_names: true)
60
+ rescue JSON::ParserError
61
+ next # a torn line (crash mid-write) loses itself, not the session
62
+ end
63
+ next if h[:seq] < from_seq
64
+
65
+ SessionEvent.new(id: h[:id], session_id: h[:session_id], seq: h[:seq],
66
+ at: Time.iso8601(h[:at]), type: h[:type], payload: h[:payload])
67
+ end
68
+ end
69
+
70
+ def session_ids
71
+ Dir[File.join(@dir, "*.jsonl")].map { |f| File.basename(f, ".jsonl") }
72
+ end
73
+
74
+ private
75
+
76
+ def path(session_id) = File.join(@dir, "#{session_id}.jsonl")
77
+ end
78
+ end
79
+ end