ask-session 0.1.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.
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ # Durable Store implementation backed by a generic ask-state-providers
6
+ # adapter (get/set/delete). Each session's record and event list persist
7
+ # under namespaced keys so sessions survive process restarts.
8
+ #
9
+ # Serialization is JSON with symbol-safe encoding: symbol values (status,
10
+ # payload symbols) round-trip exactly, and Record/Event from_h restore
11
+ # keys, timestamps, and nested structures.
12
+ #
13
+ # Concurrency: every public operation runs under an in-process mutex and,
14
+ # when the adapter exposes the provider lock API (acquire_lock /
15
+ # release_lock), under a cross-process store lock so read-modify-write
16
+ # sequences (index updates, optimistic event appends) stay atomic across
17
+ # processes and adapter connections. Adapters without lock methods fall
18
+ # back to the in-process mutex alone. Stale expected_sequence writers
19
+ # still fail with ConcurrencyError — locks serialize, they do not merge.
20
+ class ProviderStore
21
+ RECORD_PREFIX = "ask.session:record:"
22
+ EVENTS_PREFIX = "ask.session:events:"
23
+ INDEX_KEY = "ask.session:index"
24
+ STORE_LOCK_KEY = "ask.session:lock"
25
+ SYMBOL_TAG = "$ask_sym"
26
+ LOCK_TTL = 10
27
+ LOCK_TIMEOUT = 5
28
+ LOCK_RETRY_MIN_DELAY = 0.001
29
+ LOCK_RETRY_MAX_DELAY = 0.05
30
+
31
+ def initialize(adapter:, lock_ttl: LOCK_TTL, lock_timeout: LOCK_TIMEOUT)
32
+ unless adapter.respond_to?(:get) && adapter.respond_to?(:set) && adapter.respond_to?(:delete)
33
+ raise ArgumentError, "adapter must respond to get, set, and delete"
34
+ end
35
+
36
+ @adapter = adapter
37
+ @lock_ttl = lock_ttl
38
+ @lock_timeout = lock_timeout
39
+ @lockable = adapter.respond_to?(:acquire_lock) && adapter.respond_to?(:release_lock)
40
+ @mutex = Mutex.new
41
+ end
42
+
43
+ def create(id: nil, status: :active, metadata: {}, created_at: nil)
44
+ record = Record.create(id: id, status: status, metadata: metadata, created_at: created_at)
45
+ with_store_lock do
46
+ if @adapter.get(record_key(record.id))
47
+ raise DuplicateSessionError, "Session already exists: #{record.id}"
48
+ end
49
+
50
+ @adapter.set(record_key(record.id), dump(record.to_h))
51
+ @adapter.set(events_key(record.id), dump([]))
52
+ ids = read_index
53
+ ids << record.id
54
+ @adapter.set(INDEX_KEY, dump(ids))
55
+ end
56
+ record
57
+ end
58
+
59
+ def load(id)
60
+ with_store_lock { read_record(id) }
61
+ end
62
+
63
+ def load!(id)
64
+ load(id) || raise(NotFoundError, "Session not found: #{id}")
65
+ end
66
+
67
+ def list
68
+ with_store_lock { read_index.filter_map { |id| read_record(id) } }
69
+ end
70
+
71
+ def append_event(event, expected_sequence:)
72
+ with_store_lock do
73
+ unless exists?(event.session_id)
74
+ raise NotFoundError, "Session not found: #{event.session_id}"
75
+ end
76
+
77
+ session_events = read_events(event.session_id)
78
+ current_seq = session_events.size
79
+ unless current_seq == expected_sequence
80
+ raise ConcurrencyError,
81
+ "Expected sequence #{expected_sequence} but got #{current_seq} for session #{event.session_id}"
82
+ end
83
+
84
+ unless event.seq == current_seq + 1
85
+ raise ConcurrencyError,
86
+ "Event seq #{event.seq} does not match expected next sequence #{current_seq + 1} for session #{event.session_id}"
87
+ end
88
+
89
+ session_events << event
90
+ write_events(event.session_id, session_events)
91
+ end
92
+ event
93
+ end
94
+
95
+ def state(session_id)
96
+ State.reduce(session_id, events(session_id))
97
+ end
98
+
99
+ def events_after(session_id, after_seq:)
100
+ with_store_lock do
101
+ raise NotFoundError, "Session not found: #{session_id}" unless exists?(session_id)
102
+
103
+ read_events(session_id).select { |e| e.seq > after_seq }.freeze
104
+ end
105
+ end
106
+
107
+ def current_sequence(session_id)
108
+ with_store_lock do
109
+ raise NotFoundError, "Session not found: #{session_id}" unless exists?(session_id)
110
+
111
+ read_events(session_id).size
112
+ end
113
+ end
114
+
115
+ def events(session_id)
116
+ with_store_lock do
117
+ raise NotFoundError, "Session not found: #{session_id}" unless exists?(session_id)
118
+
119
+ read_events(session_id).dup.freeze
120
+ end
121
+ end
122
+
123
+ def export(session_id = nil)
124
+ with_store_lock do
125
+ if session_id
126
+ record = read_record(session_id)
127
+ raise NotFoundError, "Session not found: #{session_id}" unless record
128
+
129
+ {
130
+ sessions: [record.to_h],
131
+ events: read_events(session_id).map(&:to_h)
132
+ }
133
+ else
134
+ records = read_index.filter_map { |id| read_record(id) }
135
+ {
136
+ sessions: records.map(&:to_h),
137
+ events: records.flat_map { |r| read_events(r.id).map(&:to_h) }
138
+ }
139
+ end
140
+ end
141
+ end
142
+
143
+ def import(data)
144
+ sessions = data[:sessions] || data["sessions"] || []
145
+ events = data[:events] || data["events"] || []
146
+
147
+ with_store_lock do
148
+ pending_sessions = {}
149
+ pending_events = {}
150
+ existing_events = {}
151
+
152
+ sessions.each do |s|
153
+ id = s[:id] || s["id"]
154
+ raise SerializationError, "Session record missing id" unless id && !id.to_s.empty?
155
+ raise DuplicateSessionError, "Session already exists: #{id}" if exists?(id)
156
+ raise DuplicateSessionError, "Duplicate session in import data: #{id}" if pending_sessions.key?(id)
157
+
158
+ record = Record.from_h(s)
159
+ pending_sessions[record.id] = record
160
+ pending_events[record.id] = []
161
+ end
162
+
163
+ events.each do |e|
164
+ session_id = e[:session_id] || e["session_id"]
165
+ session_events =
166
+ if pending_events.key?(session_id)
167
+ pending_events[session_id]
168
+ elsif exists?(session_id)
169
+ existing_events[session_id] ||= read_events(session_id)
170
+ else
171
+ raise NotFoundError, "Session not found: #{session_id}"
172
+ end
173
+
174
+ event = Event.from_h(e)
175
+ expected_seq = session_events.size
176
+ unless event.seq == expected_seq + 1
177
+ raise ConcurrencyError,
178
+ "Expected sequence #{expected_seq + 1} but got #{event.seq} for session #{session_id}"
179
+ end
180
+
181
+ session_events << event
182
+ end
183
+
184
+ pending_sessions.each do |id, record|
185
+ @adapter.set(record_key(id), dump(record.to_h))
186
+ @adapter.set(events_key(id), dump(pending_events[id].map(&:to_h)))
187
+ end
188
+ existing_events.each { |id, session_events| write_events(id, session_events) }
189
+
190
+ unless pending_sessions.empty?
191
+ ids = read_index
192
+ pending_sessions.each_key { |id| ids << id }
193
+ @adapter.set(INDEX_KEY, dump(ids))
194
+ end
195
+ end
196
+ end
197
+
198
+ private
199
+
200
+ # Serialize this process's operations, then take the adapter's
201
+ # cross-process store lock when the provider exposes one. Adapters
202
+ # without lock APIs fall back to the in-process mutex alone.
203
+ def with_store_lock
204
+ @mutex.synchronize do
205
+ lock = acquire_store_lock if @lockable
206
+ begin
207
+ yield
208
+ ensure
209
+ release_store_lock(lock) if lock
210
+ end
211
+ end
212
+ end
213
+
214
+ # Spin (bounded) until the provider lock is acquired. The lock is
215
+ # TTL-bounded by the adapter, so a crashed holder cannot wedge the
216
+ # store forever; exhausting the wait budget surfaces as
217
+ # ConcurrencyError rather than silently dropping mutual exclusion.
218
+ #
219
+ # Transient adapter errors during acquisition (e.g. a check-then-insert
220
+ # race between connections on the lock row) are retried within the
221
+ # same budget — losing the race means the lock is held elsewhere.
222
+ def acquire_store_lock
223
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
224
+ delay = LOCK_RETRY_MIN_DELAY
225
+ last_error = nil
226
+ loop do
227
+ begin
228
+ lock = @adapter.acquire_lock(STORE_LOCK_KEY, ttl: @lock_ttl)
229
+ return lock if lock
230
+ rescue StandardError => e
231
+ last_error = e
232
+ end
233
+
234
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
235
+ message = "Timed out after #{@lock_timeout}s acquiring store lock"
236
+ message = "#{message} (last error: #{last_error.message})" if last_error
237
+ raise ConcurrencyError, message
238
+ end
239
+
240
+ sleep(delay)
241
+ delay = [delay * 2, LOCK_RETRY_MAX_DELAY].min
242
+ end
243
+ end
244
+
245
+ # A failed release must never mask the operation's own outcome —
246
+ # the adapter's TTL reclaims the lock if the token delete failed.
247
+ def release_store_lock(lock)
248
+ @adapter.release_lock(STORE_LOCK_KEY, lock)
249
+ rescue StandardError
250
+ nil
251
+ end
252
+
253
+ def exists?(id)
254
+ !@adapter.get(record_key(id)).nil?
255
+ end
256
+
257
+ def read_record(id)
258
+ raw = @adapter.get(record_key(id))
259
+ return nil unless raw
260
+
261
+ Record.from_h(decode(JSON.parse(raw)))
262
+ end
263
+
264
+ def read_events(id)
265
+ raw = @adapter.get(events_key(id))
266
+ return [] unless raw
267
+
268
+ decode(JSON.parse(raw)).map { |h| Event.from_h(h) }
269
+ end
270
+
271
+ def write_events(id, session_events)
272
+ @adapter.set(events_key(id), dump(session_events.map(&:to_h)))
273
+ end
274
+
275
+ def read_index
276
+ raw = @adapter.get(INDEX_KEY)
277
+ raw ? decode(JSON.parse(raw)) : []
278
+ end
279
+
280
+ def record_key(id)
281
+ "#{RECORD_PREFIX}#{id}"
282
+ end
283
+
284
+ def events_key(id)
285
+ "#{EVENTS_PREFIX}#{id}"
286
+ end
287
+
288
+ def dump(obj)
289
+ JSON.generate(encode(obj))
290
+ end
291
+
292
+ def encode(obj)
293
+ case obj
294
+ when Symbol then { SYMBOL_TAG => obj.to_s }
295
+ when Hash
296
+ obj.each_with_object({}) { |(k, v), acc| acc[k] = encode(v) }
297
+ when Array then obj.map { |v| encode(v) }
298
+ else obj
299
+ end
300
+ end
301
+
302
+ def decode(obj)
303
+ case obj
304
+ when Hash
305
+ if obj.size == 1 && obj.key?(SYMBOL_TAG) && obj[SYMBOL_TAG].is_a?(String)
306
+ obj[SYMBOL_TAG].to_sym
307
+ else
308
+ obj.each_with_object({}) { |(k, v), acc| acc[k] = decode(v) }
309
+ end
310
+ when Array then obj.map { |v| decode(v) }
311
+ else obj
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ Record = Struct.new(:id, :status, :metadata, :created_at, :updated_at, :version, keyword_init: true) do
6
+ def self.create(id: nil, status: :active, metadata: {}, created_at: nil, updated_at: nil)
7
+ now = created_at || Time.now.utc
8
+ new(
9
+ id: id || "sess_#{SecureRandom.hex(8)}",
10
+ status: status,
11
+ metadata: metadata,
12
+ created_at: now,
13
+ updated_at: updated_at || now,
14
+ version: 0
15
+ ).freeze
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ id: id,
21
+ status: status,
22
+ metadata: metadata,
23
+ created_at: created_at&.utc&.iso8601,
24
+ updated_at: updated_at&.utc&.iso8601,
25
+ version: version
26
+ }
27
+ end
28
+
29
+ def self.from_h(h)
30
+ new(
31
+ id: h[:id] || h["id"],
32
+ status: (h[:status] || h["status"])&.to_sym,
33
+ metadata: deep_symbolize(h[:metadata] || h["metadata"]),
34
+ created_at: parse_time(h[:created_at] || h["created_at"]),
35
+ updated_at: parse_time(h[:updated_at] || h["updated_at"]),
36
+ version: h[:version] || h["version"]
37
+ )
38
+ end
39
+
40
+ def self.deep_symbolize(obj)
41
+ case obj
42
+ when Hash
43
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
44
+ when Array
45
+ obj.map { |v| deep_symbolize(v) }
46
+ else
47
+ obj
48
+ end
49
+ end
50
+ private_class_method :deep_symbolize
51
+
52
+ def self.parse_time(value)
53
+ case value
54
+ when Time then value
55
+ when String then Time.parse(value).utc
56
+ when nil then nil
57
+ else
58
+ raise SerializationError, "Invalid time value: #{value.inspect}"
59
+ end
60
+ end
61
+ private_class_method :parse_time
62
+
63
+ def initialize(**)
64
+ super
65
+ self.metadata = deep_freeze(metadata) unless metadata.frozen?
66
+ freeze
67
+ end
68
+
69
+ def with_updates(**attrs)
70
+ merged = {
71
+ id: id,
72
+ status: status,
73
+ metadata: metadata,
74
+ created_at: created_at,
75
+ updated_at: attrs[:updated_at] || Time.now.utc,
76
+ version: version
77
+ }.merge(attrs)
78
+ self.class.new(**merged).freeze
79
+ end
80
+
81
+ private
82
+
83
+ def deep_freeze(obj)
84
+ case obj
85
+ when Hash
86
+ obj.each_with_object({}) { |(k, v), h| h[k] = deep_freeze(v) }.freeze
87
+ when Array
88
+ obj.map { |v| deep_freeze(v) }.freeze
89
+ else
90
+ obj
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ # Bridge from the ask-runtime event-sink contract to a session Host.
6
+ #
7
+ # ask-runtime executors (ask-agent, ask-mcp, ask-sandbox-providers)
8
+ # report tool lifecycle through +ExecutionContext#event_sink+ by calling
9
+ # <tt>emit(event_type, event: event)</tt> with the immutable runtime
10
+ # events (+:tool_started+, +:tool_completed+, +:tool_failed+,
11
+ # +:tool_cancelled+, +:tool_timed_out+). This sink implements that
12
+ # producer side and appends the mapped session events through
13
+ # +Host#append+, so tool history becomes part of the event-sourced
14
+ # session without ask-session depending on ask-runtime.
15
+ #
16
+ # sink = host.sink("s1", trace_id: current_trace_id)
17
+ # context = Ask::Runtime::ExecutionContext.new(session_id: "s1", event_sink: sink)
18
+ #
19
+ # Mapping (runtime symbol -> session event type):
20
+ #
21
+ # :tool_started -> "tool.started"
22
+ # :tool_completed -> "tool.completed"
23
+ # :tool_failed -> "tool.failed"
24
+ # :tool_cancelled -> "tool.cancelled"
25
+ # :tool_timed_out -> "tool.timed_out"
26
+ #
27
+ # Payloads are extracted duck-typed from the event's public readers
28
+ # (+tool_name+, +tool_call_id+, +duration+, +error+, +reason+,
29
+ # +tool_call+, +execution_context+, +tool_result+), so no runtime
30
+ # classes are required. Nil values are omitted.
31
+ #
32
+ # Terminal events carry +outcome+ (+:completed+, +:failed+,
33
+ # +:cancelled+, +:timed_out+ — the ToolCall state vocabulary),
34
+ # +duration+ in seconds, and +error+ when a failure or cancellation
35
+ # reason is available.
36
+ #
37
+ # Guards:
38
+ # - A correlated event whose session id differs from the sink's
39
+ # session raises SessionMismatchError (never cross-write sessions).
40
+ # - Appends to a closed or aborted session are dropped silently:
41
+ # terminal sessions stop recording, but a tool run already in
42
+ # flight must not fail because recording ended.
43
+ # - A mapped type emitted without an +event:+ raises ArgumentError.
44
+ # - Unknown event types are ignored (additive runtime growth).
45
+ # - Missing sessions still raise NotFoundError (wiring bug).
46
+ class Sink
47
+ MAPPING = {
48
+ tool_started: "tool.started",
49
+ tool_completed: "tool.completed",
50
+ tool_failed: "tool.failed",
51
+ tool_cancelled: "tool.cancelled",
52
+ tool_timed_out: "tool.timed_out"
53
+ }.freeze
54
+
55
+ DEFAULT_OUTCOMES = {
56
+ "tool.completed" => :completed,
57
+ "tool.failed" => :failed,
58
+ "tool.cancelled" => :cancelled,
59
+ "tool.timed_out" => :timed_out
60
+ }.freeze
61
+
62
+ attr_reader :host, :session_id, :trace_id, :causation_id
63
+
64
+ def initialize(host:, session_id:, trace_id: nil, causation_id: nil)
65
+ @host = host
66
+ @session_id = session_id
67
+ @trace_id = trace_id
68
+ @causation_id = causation_id
69
+ end
70
+
71
+ # Producer side of the ask-runtime EventSink contract.
72
+ #
73
+ # @param event_type [Symbol, String] runtime event name
74
+ # @param event [Object, nil] the runtime event (required for mapped types)
75
+ # @return [self]
76
+ # @raise [ArgumentError] when a mapped type is emitted without an event
77
+ # @raise [SessionMismatchError] when the event correlates to another session
78
+ # @raise [NotFoundError] when the sink's session does not exist
79
+ def emit(event_type, event: nil)
80
+ type = MAPPING[coerce(event_type)]
81
+ return self unless type
82
+
83
+ raise ArgumentError, "event: is required for #{event_type.inspect}" if event.nil?
84
+
85
+ mismatched = conflicting_session_id(event)
86
+ if mismatched
87
+ raise SessionMismatchError,
88
+ "Event for session #{mismatched.inspect} cannot be recorded in session #{@session_id.inspect}"
89
+ end
90
+
91
+ append(type, event)
92
+ self
93
+ rescue InvalidTransitionError
94
+ self
95
+ end
96
+
97
+ # Whether this sink records the given runtime event type.
98
+ def listening?(event_type)
99
+ MAPPING.key?(coerce(event_type))
100
+ end
101
+
102
+ private
103
+
104
+ def coerce(event_type)
105
+ event_type.respond_to?(:to_sym) ? event_type.to_sym : event_type
106
+ end
107
+
108
+ def append(type, event)
109
+ @host.append(
110
+ @session_id,
111
+ type: type,
112
+ payload: build_payload(type, event),
113
+ trace_id: @trace_id,
114
+ causation_id: @causation_id
115
+ )
116
+ end
117
+
118
+ def build_payload(type, event)
119
+ payload = {}
120
+ add(payload, :tool_name, read(event, :tool_name))
121
+ add(payload, :tool_call_id, read(event, :tool_call_id))
122
+ add(payload, :turn, turn_of(event))
123
+ add(payload, :input, input_of(event)) if type == "tool.started"
124
+
125
+ unless type == "tool.started"
126
+ add(payload, :outcome, outcome_of(type, event))
127
+ add(payload, :duration, read(event, :duration))
128
+ add(payload, :error, error_of(event))
129
+ output = output_of(event)
130
+ add(payload, :output, output) unless output.nil?
131
+ end
132
+
133
+ payload
134
+ end
135
+
136
+ def add(payload, key, value)
137
+ payload[key] = value unless value.nil?
138
+ end
139
+
140
+ def read(object, method)
141
+ object.public_send(method) if object.respond_to?(method)
142
+ end
143
+
144
+ def turn_of(event)
145
+ ctx = read(event, :execution_context)
146
+ read(ctx, :turn)
147
+ end
148
+
149
+ def input_of(event)
150
+ call = read(event, :tool_call)
151
+ read(call, :input)
152
+ end
153
+
154
+ def outcome_of(type, event)
155
+ call = read(event, :tool_call)
156
+ read(call, :state) || DEFAULT_OUTCOMES[type]
157
+ end
158
+
159
+ def error_of(event)
160
+ read(event, :error) || read(event, :reason) || begin
161
+ result = read(event, :tool_result)
162
+ read(result, :error_message)
163
+ end
164
+ end
165
+
166
+ def output_of(event)
167
+ result = read(event, :tool_result)
168
+ read(result, :output)
169
+ end
170
+
171
+ def conflicting_session_id(event)
172
+ found = session_id_from(event)
173
+ found if found && found != @session_id
174
+ end
175
+
176
+ def session_id_from(event)
177
+ call = read(event, :tool_call)
178
+ from_call = read(call, :session_id)
179
+ return from_call if from_call
180
+
181
+ ctx = read(event, :execution_context)
182
+ from_ctx = read(ctx, :session_id)
183
+ return from_ctx if from_ctx
184
+
185
+ read(event, :session_id)
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ module State
6
+ def self.reduce(session_id, events)
7
+ record = nil
8
+
9
+ events.each do |event|
10
+ record = apply_event(record, event)
11
+ end
12
+
13
+ (record || Record.create(id: session_id, created_at: Time.now.utc)).freeze
14
+ end
15
+
16
+ def self.apply_event(record, event)
17
+ case event.type
18
+ when "session.created"
19
+ Record.new(
20
+ id: event.session_id,
21
+ status: event.payload.fetch(:status, :active),
22
+ metadata: event.payload.fetch(:metadata, {}),
23
+ created_at: event.created_at,
24
+ updated_at: event.created_at,
25
+ version: event.seq
26
+ )
27
+ when "session.status_changed", "session.ended", "session.aborted"
28
+ raise "No session to update" unless record
29
+
30
+ record.with_updates(
31
+ status: event.payload.fetch(:status, record.status),
32
+ version: event.seq,
33
+ updated_at: event.created_at
34
+ )
35
+ when "message.added", "tool.completed"
36
+ raise "No session to update" unless record
37
+
38
+ record.with_updates(
39
+ version: event.seq,
40
+ updated_at: event.created_at
41
+ )
42
+ else
43
+ raise "No session to update" unless record
44
+
45
+ record.with_updates(
46
+ version: event.seq,
47
+ updated_at: event.created_at
48
+ )
49
+ end
50
+ end
51
+ private_class_method :apply_event
52
+ end
53
+ end
54
+ end