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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ce03eac4e879e1e478ee60d22b6f30b374c05c6e35a5a88fc0566c58e972a09b
4
+ data.tar.gz: b47546ef122d601885718f767c7c778aaac8e89d59d835401e9865de12f3743d
5
+ SHA512:
6
+ metadata.gz: 4f6fd701a98a0e6f98259bdeb2b5299bbba3ba76cc3d3a617e795f61e4d341f697c491d068663efb86e10362eec4a80588953704afc91166d55e9022e75802e3
7
+ data.tar.gz: d3b0922f243b949ee8062608c016cc99388fabe16f547e53fe5a876cdc73bd547c972e0fe2c5f5b74e611bd3ce93a867318114e1bc43de9e09eb440f9afb8f77
data/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-22
11
+
12
+ ### Added
13
+
14
+ - `Ask::Session::Record` — immutable value object for session snapshots.
15
+ - `Ask::Session::Event` — immutable event envelope with sequence, type, and causation.
16
+ - `Ask::Session::Store` — in-memory store with concurrency guardrails.
17
+ - `Ask::Session::State` — pure reducer that reconstructs session state from events.
18
+ - `Ask::Session::ConcurrencyError` — dedicated error for sequence mismatches.
19
+ - `Ask::Session::SerializationError` — dedicated error for malformed/missing-field JSON.
20
+ - `Ask::Session::NotFoundError` — dedicated error when a session id does not exist.
21
+ - `Ask::Session::DuplicateSessionError` — dedicated error when creating or importing a session id that already exists.
22
+ - `Store#load!` — loads a session or raises `NotFoundError`.
23
+ - `Store#current_sequence` — current event count for a session.
24
+ - `Record#to_h` / `Record.from_h` — portable hash serialization with ISO8601 timestamps.
25
+ - `Event#to_h` / `Event.from_h` — portable hash serialization with ISO8601 timestamps.
26
+ - `Ask::Session::Codec` — JSON dump/load for Record and Event.
27
+ - `Store#events` — returns a frozen array of all events for a session.
28
+ - `Store#export` / `Store#import` — full session portability with duplicate and sequence validation.
29
+ - `Store#state(session_id)` — reconstructs session state from stored events via the reducer.
30
+ - `Store#export(session_id)` — exports a single session payload for targeted import.
31
+ - `Store#import` — accepts both all-sessions and single-session shapes; validates atomically before mutating.
32
+ - `Store#events_after` — now returns a frozen array snapshot.
33
+ - `Ask::Session::Host` — replayable session host with create, session, list, events, send_message, close, abort, subscribe, and publish-subscribe.
34
+ - `Host#append(session_id, type:, payload:, trace_id:, causation_id:)` — generic event append for arbitrary event types (tool calls, vendor webhooks, custom lifecycle events) without coupling ask-session to any protocol or agent gem.
35
+ - `Ask::Session::ProviderStore` — durable store backed by any adapter responding to `get`, `set`, and `delete` (e.g. the ask-state-providers SQLite/Redis/Postgres/MySQL adapters): namespaced record/event keys, a session index, and symbol-safe JSON round-trip so sessions, events, and reduced state survive process restarts when a `Host` is built over it.
36
+ - `ProviderStore` cross-process locking — when the adapter exposes `acquire_lock`/`release_lock`, every public operation runs under a TTL-bounded store lock with bounded retry (an exhausted wait budget raises `ConcurrencyError`); adapters without lock methods fall back to an in-process mutex. Stale `expected_sequence` writers still fail with `ConcurrencyError` — locks serialize, they do not merge.
37
+ - `ProviderStore#export` / `ProviderStore#import` — portability parity with `Store`, accepting both all-sessions and single-session shapes with atomic validation before mutating.
38
+ - `Ask::Session::Subscription` — thread-safe subscription with next (Timeout.timeout-backed), wait (alias for next), close, each, and replay.
39
+ - `Ask::Session::InvalidTransitionError` — dedicated error for illegal state transitions (send to closed/aborted, close/abort twice).
40
+ - `Ask::Session::Sink` — ask-runtime event-sink bridge: implements `emit(event_type, event:)` and maps `:tool_started` / `:tool_completed` / `:tool_failed` / `:tool_cancelled` / `:tool_timed_out` to `tool.started` / `tool.completed` / `tool.failed` / `tool.cancelled` / `tool.timed_out` session events via `Host#append`. Duck-typed extraction (no ask-runtime dependency); payloads carry `tool_name`, `tool_call_id`, `input` (started), `outcome`, `duration`, `error`, `output` (terminal), and `turn` when present.
41
+ - `Ask::Session::SessionMismatchError` — dedicated error when a sink receives an event correlated to a different session.
42
+ - `Host#sink(session_id, trace_id:, causation_id:)` — factory that binds a `Sink` to a host and session.
43
+
44
+ ### Changed
45
+
46
+ - `Host#initialize` now accepts `store:` with a default of `Store.new`.
47
+ - `Host#list` returns `State.reduce` records so close/abort status is reflected.
48
+ - `Host#create` performs store create, event append, and publish atomically under `@mutex`.
49
+ - `Host#publish` prunes closed subscriptions from its per-session list.
50
+ - `Subscription#next` is the primary method using `Queue#pop` wrapped in `Timeout.timeout`; `wait` is aliased to `next` for backward compatibility.
51
+ - `Subscription#close` pushes a sentinel so a blocked `next` wakes with `nil` instead of relying on sleep polling.
52
+ - `Subscription#each` now blocks until close instead of timing out after 0.1s of silence — it yields across quiet periods and only stops when the subscription is closed (restores the documented "yields until closed" contract).
53
+ - `Sink#emit` drops (does not raise) appends to closed or aborted sessions so in-flight tool runs are never failed by the recording boundary; missing sessions still raise `NotFoundError`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # Ask::Session
2
+
3
+ Event-sourced session state for the [ask-rb](https://github.com/ask-rb) ecosystem.
4
+
5
+ ## Installation
6
+
7
+ Requires Ruby 3.2+.
8
+
9
+ ```ruby
10
+ gem "ask-session"
11
+ ```
12
+
13
+ ## Overview
14
+
15
+ Ask::Session provides the foundational value objects and stores for event-sourced session management in ask-rb. It defines immutable records, event envelopes, a concurrency-safe in-memory store, a durable provider-backed store for restart-safe sessions, a replayable host with subscriptions, and a state reducer.
16
+
17
+ ### Core Types
18
+
19
+ - **`Record`** — immutable snapshot of a session: `id`, `status`, `metadata`, `created_at`, `updated_at`, `version`.
20
+ - **`Event`** — immutable event envelope: `session_id`, `seq`, `type`, `payload`, `trace_id`, `causation_id`, `created_at`.
21
+
22
+ ### Serialization
23
+
24
+ `Record` and `Event` support portable JSON serialization via `to_h`/`from_h` and the `Codec` module:
25
+
26
+ ```ruby
27
+ # Hash round-trip
28
+ record = Ask::Session::Record.create(id: "s1", status: :active, metadata: { key: "val" })
29
+ hash = record.to_h # => { id: "s1", status: :active, ..., created_at: <ISO8601>, version: 0 }
30
+ restored = Ask::Session::Record.from_h(hash)
31
+
32
+ # JSON via Codec
33
+ json = Ask::Session::Codec.dump_record(record)
34
+ record = Ask::Session::Codec.load_record(json)
35
+
36
+ # Events work the same way
37
+ event = Ask::Session::Event.create(session_id: "s1", seq: 1, type: "session.created")
38
+ json = Ask::Session::Codec.dump_event(event)
39
+ event = Ask::Session::Codec.load_event(json)
40
+ ```
41
+
42
+ Malformed JSON or missing required fields raise `Ask::Session::SerializationError`.
43
+
44
+ ### Store
45
+
46
+ In-memory event store with optimistic concurrency control:
47
+
48
+ ```ruby
49
+ store = Ask::Session::Store.new
50
+
51
+ # Create a session
52
+ store.create(id: "sess_001")
53
+
54
+ # Append events with expected sequence
55
+ event = Ask::Session::Event.new(
56
+ session_id: "sess_001", seq: 1, type: "session.created",
57
+ payload: { status: :active }, created_at: Time.now
58
+ )
59
+ store.append_event(event, expected_sequence: 0)
60
+
61
+ # Load session events
62
+ events = store.events_after("sess_001", after_seq: 0)
63
+
64
+ # Get all events for a session (frozen)
65
+ events = store.events("sess_001")
66
+
67
+ # Export/import for portability (all sessions)
68
+ data = store.export
69
+ new_store = Ask::Session::Store.new
70
+ new_store.import(data)
71
+
72
+ # Export a single session into a fresh store
73
+ single = store.export("sess_001")
74
+ fresh = Ask::Session::Store.new
75
+ fresh.import(single)
76
+
77
+ # Rebuild session state from events
78
+ record = store.state("sess_001")
79
+ record.status # => :active
80
+ record.version # => 1
81
+ ```
82
+
83
+ ### ProviderStore (durable)
84
+
85
+ `ProviderStore` is the durable counterpart to `Store`: same API (`create`, `load`, `load!`, `list`, `append_event`, `events`, `events_after`, `current_sequence`, `state`, `export`/`import`), but persisted through any adapter that responds to `get`, `set`, and `delete`. The [ask-state-providers](https://github.com/ask-rb/ask-state-providers) adapters (SQLite, Redis, Postgres, MySQL) work out of the box:
86
+
87
+ ```ruby
88
+ require "ask-state-providers"
89
+
90
+ adapter = Ask::State::Providers::SQLite.new(path: "sessions.db")
91
+ host = Ask::Session::Host.new(store: Ask::Session::ProviderStore.new(adapter: adapter))
92
+
93
+ host.create(id: "s1", metadata: { user: "alice" })
94
+ host.send_message("s1", content: "hello")
95
+ host.close("s1", reason: "done")
96
+ adapter.close
97
+
98
+ # Later — same file, new process:
99
+ adapter = Ask::State::Providers::SQLite.new(path: "sessions.db")
100
+ host = Ask::Session::Host.new(store: Ask::Session::ProviderStore.new(adapter: adapter))
101
+ host.session("s1") # => reduced Record with status: :closed, version: 3
102
+ host.events("s1") # full history survives the restart
103
+ ```
104
+
105
+ Each session's record and event list persist under namespaced keys (`ask.session:record:*`, `ask.session:events:*`) alongside a session index, using JSON with symbol-safe encoding so statuses and payload symbols round-trip exactly.
106
+
107
+ When the adapter exposes the provider lock API (`acquire_lock` / `release_lock`), every operation also runs under a cross-process store lock so read-modify-write sequences stay atomic across processes; exhausting the lock wait budget raises `Ask::Session::ConcurrencyError`. Adapters without lock methods fall back to an in-process mutex. Stale `expected_sequence` writers still fail with `ConcurrencyError` — locks serialize, they do not merge.
108
+
109
+ ask-session keeps zero runtime dependencies: `ProviderStore` duck-types the adapter, so `ask-state-providers` is an optional integration, not a dependency of this gem.
110
+
111
+ ### State Reducer
112
+
113
+ Rebuild session state from event history:
114
+
115
+ ```ruby
116
+ events = [
117
+ Ask::Session::Event.new(session_id: "s1", seq: 1, type: "session.created",
118
+ payload: { status: :active }, created_at: Time.now),
119
+ Ask::Session::Event.new(session_id: "s1", seq: 2, type: "message.added",
120
+ payload: { role: :user, content: "hello" }, created_at: Time.now)
121
+ ]
122
+
123
+ record = Ask::Session::State.reduce("s1", events)
124
+ record.status # => :active
125
+ record.version # => 2
126
+ ```
127
+
128
+ ### Host
129
+
130
+ Replayable session host with publish-subscribe:
131
+
132
+ ```ruby
133
+ store = Ask::Session::Store.new
134
+ host = Ask::Session::Host.new(store: store)
135
+
136
+ # Create a session
137
+ record = host.create(id: "s1", metadata: { user: "alice" })
138
+ record.status # => :active
139
+
140
+ # Send messages
141
+ event = host.send_message("s1", content: "hello")
142
+ event.type # => "message.added"
143
+
144
+ # Query
145
+ host.session("s1") # => reduced Record
146
+ host.list # => [Record, ...]
147
+ host.events("s1") # => [Event, ...]
148
+
149
+ # Subscribe with replay
150
+ sub = host.subscribe("s1")
151
+ event = sub.next(timeout: 1.0) # returns event or nil on timeout
152
+ sub.each { |e| puts e.type } # yields until closed
153
+ sub.close
154
+
155
+ # Close or abort
156
+ host.close("s1", reason: "done")
157
+ host.abort("s1", reason: "error")
158
+ ```
159
+
160
+ Invalid transitions (send to closed/aborted, close twice) raise `Ask::Session::InvalidTransitionError`.
161
+
162
+ #### Generic event append
163
+
164
+ `Host#append` lets adapters record arbitrary event types (tool calls, vendor webhooks, custom lifecycle events) without coupling ask-session to any protocol or agent gem:
165
+
166
+ ```ruby
167
+ # Append a tool event
168
+ event = host.append("s1", type: "tool.started", payload: { tool: "search" })
169
+
170
+ # Append with trace correlation
171
+ event = host.append("s1",
172
+ type: "vendor.webhook.received",
173
+ payload: { raw: body },
174
+ trace_id: "trace_abc",
175
+ causation_id: originating_event.trace_id
176
+ )
177
+ ```
178
+
179
+ Every appended event advances the session's reduced `version` and `updated_at`, regardless of event type. Appends to closed or aborted sessions raise `InvalidTransitionError`. Adapters use `append` instead of `send_message` when the event type is not `message.added` — this keeps ask-session free of protocol and agent dependencies.
180
+
181
+ #### Runtime tool-lifecycle sink
182
+
183
+ `Ask::Session::Sink` is the boundary to ask-runtime's event-sink contract. Executors in ask-agent, ask-mcp, and ask-sandbox-providers report tool lifecycle through `ExecutionContext#event_sink` by calling `emit(event_type, event:)` — point that sink at a session host and tool history becomes part of the event-sourced session:
184
+
185
+ ```ruby
186
+ host = Ask::Session::Host.new
187
+ host.create(id: "s1")
188
+
189
+ sink = host.sink("s1", trace_id: "trace_abc")
190
+ # pass `sink` as ExecutionContext's event_sink; runtime emits then record:
191
+ # :tool_started -> tool.started
192
+ # :tool_completed -> tool.completed
193
+ # :tool_failed -> tool.failed
194
+ # :tool_cancelled -> tool.cancelled
195
+ # :tool_timed_out -> tool.timed_out
196
+
197
+ host.events("s1").last.payload
198
+ # => { tool_name: "bash", tool_call_id: "tc_1", input: { "cmd" => "ls" }, turn: 3 }
199
+ ```
200
+
201
+ Terminal events carry `outcome` (`:completed`/`:failed`/`:cancelled`/`:timed_out`), `duration` in seconds, `error` when present, and `output` when the runtime result exposes one. The sink duck-types the runtime events' public readers, so ask-session keeps zero runtime dependencies.
202
+
203
+ Guards: an event correlated to a different session raises `Ask::Session::SessionMismatchError`; appends to closed/aborted sessions are dropped (terminal sessions stop recording without failing an in-flight tool run); missing sessions raise `NotFoundError`; unknown event types are ignored.
204
+
205
+ ```ruby
206
+ sink = Ask::Session::Sink.new(host: host, session_id: "s1", causation_id: originating.trace_id)
207
+ sink.listening?(:tool_started) # => true
208
+ ```
209
+
210
+ ## Contributing
211
+
212
+ 1. Fork it
213
+ 2. Create your feature branch (`git checkout -b my-feature`)
214
+ 3. Commit your changes (`git commit -am 'Add feature'`)
215
+ 4. Push to the branch (`git push origin my-feature`)
216
+ 5. Create a Pull Request
217
+
218
+ ## License
219
+
220
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ module Codec
6
+ RECORD_KEYS = %i[id status metadata created_at updated_at version].freeze
7
+ EVENT_KEYS = %i[session_id seq type payload trace_id causation_id created_at].freeze
8
+
9
+ def self.dump_record(record)
10
+ JSON.generate(record.to_h)
11
+ end
12
+
13
+ def self.load_record(json)
14
+ h = parse_json(json)
15
+ validate_keys!(h, RECORD_KEYS, "Record")
16
+ Record.from_h(symbolize(h))
17
+ rescue JSON::ParserError => e
18
+ raise SerializationError, "Invalid JSON for Record: #{e.message}"
19
+ end
20
+
21
+ def self.dump_event(event)
22
+ JSON.generate(event.to_h)
23
+ end
24
+
25
+ def self.load_event(json)
26
+ h = parse_json(json)
27
+ validate_keys!(h, EVENT_KEYS, "Event")
28
+ Event.from_h(symbolize(h))
29
+ rescue JSON::ParserError => e
30
+ raise SerializationError, "Invalid JSON for Event: #{e.message}"
31
+ end
32
+
33
+ def self.parse_json(json)
34
+ raise SerializationError, "Input must be a String" unless json.is_a?(String)
35
+ JSON.parse(json)
36
+ end
37
+ private_class_method :parse_json
38
+
39
+ def self.validate_keys!(h, required, label)
40
+ required.each do |key|
41
+ ks = key.to_s
42
+ next if h.key?(ks) && !h[ks].nil?
43
+
44
+ raise SerializationError, "Missing required field '#{ks}' in #{label}"
45
+ end
46
+ end
47
+ private_class_method :validate_keys!
48
+
49
+ def self.symbolize(h)
50
+ h.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
51
+ end
52
+ private_class_method :symbolize
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ Event = Struct.new(:session_id, :seq, :type, :payload, :trace_id, :causation_id, :created_at, keyword_init: true) do
6
+ def self.create(session_id:, seq:, type:, payload: {}, trace_id: nil, causation_id: nil, created_at: nil)
7
+ new(
8
+ session_id: session_id,
9
+ seq: seq,
10
+ type: type,
11
+ payload: payload,
12
+ trace_id: trace_id || "trace_#{SecureRandom.hex(8)}",
13
+ causation_id: causation_id,
14
+ created_at: created_at || Time.now.utc
15
+ ).freeze
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ session_id: session_id,
21
+ seq: seq,
22
+ type: type,
23
+ payload: payload,
24
+ trace_id: trace_id,
25
+ causation_id: causation_id,
26
+ created_at: created_at&.utc&.iso8601
27
+ }
28
+ end
29
+
30
+ def self.from_h(h)
31
+ new(
32
+ session_id: h[:session_id] || h["session_id"],
33
+ seq: h[:seq] || h["seq"],
34
+ type: h[:type] || h["type"],
35
+ payload: deep_symbolize(h[:payload] || h["payload"]),
36
+ trace_id: h[:trace_id] || h["trace_id"],
37
+ causation_id: h[:causation_id] || h["causation_id"],
38
+ created_at: parse_time(h[:created_at] || h["created_at"])
39
+ )
40
+ end
41
+
42
+ def self.deep_symbolize(obj)
43
+ case obj
44
+ when Hash
45
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
46
+ when Array
47
+ obj.map { |v| deep_symbolize(v) }
48
+ else
49
+ obj
50
+ end
51
+ end
52
+ private_class_method :deep_symbolize
53
+
54
+ def self.parse_time(value)
55
+ case value
56
+ when Time then value
57
+ when String then Time.parse(value).utc
58
+ when nil then nil
59
+ else
60
+ raise SerializationError, "Invalid time value: #{value.inspect}"
61
+ end
62
+ end
63
+ private_class_method :parse_time
64
+
65
+ def initialize(**)
66
+ super
67
+ self.payload = deep_freeze(payload) unless payload.frozen?
68
+ freeze
69
+ end
70
+
71
+ private
72
+
73
+ def deep_freeze(obj)
74
+ case obj
75
+ when Hash
76
+ obj.each_with_object({}) { |(k, v), h| h[k] = deep_freeze(v) }.freeze
77
+ when Array
78
+ obj.map { |v| deep_freeze(v) }.freeze
79
+ else
80
+ obj
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Session
5
+ class Host
6
+ def initialize(store: Store.new)
7
+ @store = store
8
+ @subscriptions = {}
9
+ @mutex = Mutex.new
10
+ @sub_id_counter = 0
11
+ end
12
+
13
+ def create(id: nil, metadata: {}, status: :active, trace_id: nil)
14
+ @mutex.synchronize do
15
+ record = @store.create(id: id, status: status, metadata: metadata)
16
+ event = Event.create(
17
+ session_id: record.id,
18
+ seq: 1,
19
+ type: "session.created",
20
+ payload: { session_id: record.id, metadata: metadata, status: status },
21
+ trace_id: trace_id
22
+ )
23
+ @store.append_event(event, expected_sequence: 0)
24
+ publish(event)
25
+ State.reduce(record.id, @store.events(record.id))
26
+ end
27
+ end
28
+
29
+ def session(id)
30
+ State.reduce(id, @store.events(id))
31
+ end
32
+
33
+ def list
34
+ @store.list.map { |record| State.reduce(record.id, @store.events(record.id)) }
35
+ end
36
+
37
+ def events(id, after_seq: 0)
38
+ @store.events_after(id, after_seq: after_seq)
39
+ end
40
+
41
+ def send_message(session_id, content:, trace_id: nil, causation_id: nil)
42
+ @mutex.synchronize do
43
+ record = current_state(session_id)
44
+ assert_open!(record)
45
+
46
+ seq = @store.current_sequence(session_id) + 1
47
+ event = Event.create(
48
+ session_id: session_id,
49
+ seq: seq,
50
+ type: "message.added",
51
+ payload: { content: content },
52
+ trace_id: trace_id,
53
+ causation_id: causation_id
54
+ )
55
+ @store.append_event(event, expected_sequence: seq - 1)
56
+ publish(event)
57
+ event
58
+ end
59
+ end
60
+
61
+ def append(session_id, type:, payload: {}, trace_id: nil, causation_id: nil)
62
+ @mutex.synchronize do
63
+ record = current_state(session_id)
64
+ assert_open!(record)
65
+
66
+ seq = @store.current_sequence(session_id) + 1
67
+ event = Event.create(
68
+ session_id: session_id,
69
+ seq: seq,
70
+ type: type,
71
+ payload: payload,
72
+ trace_id: trace_id,
73
+ causation_id: causation_id
74
+ )
75
+ @store.append_event(event, expected_sequence: seq - 1)
76
+ publish(event)
77
+ event
78
+ end
79
+ end
80
+
81
+ def close(session_id, reason: nil)
82
+ @mutex.synchronize do
83
+ record = current_state(session_id)
84
+ assert_open!(record)
85
+
86
+ seq = @store.current_sequence(session_id) + 1
87
+ event = Event.create(
88
+ session_id: session_id,
89
+ seq: seq,
90
+ type: "session.ended",
91
+ payload: { status: :closed, reason: reason }
92
+ )
93
+ @store.append_event(event, expected_sequence: seq - 1)
94
+ publish(event)
95
+ State.reduce(session_id, @store.events(session_id))
96
+ end
97
+ end
98
+
99
+ def abort(session_id, reason: nil)
100
+ @mutex.synchronize do
101
+ record = current_state(session_id)
102
+ assert_open!(record)
103
+
104
+ seq = @store.current_sequence(session_id) + 1
105
+ event = Event.create(
106
+ session_id: session_id,
107
+ seq: seq,
108
+ type: "session.aborted",
109
+ payload: { status: :aborted, reason: reason }
110
+ )
111
+ @store.append_event(event, expected_sequence: seq - 1)
112
+ publish(event)
113
+ State.reduce(session_id, @store.events(session_id))
114
+ end
115
+ end
116
+
117
+ def subscribe(session_id, after_seq: 0)
118
+ @mutex.synchronize do
119
+ replay = @store.events_after(session_id, after_seq: after_seq)
120
+ sub_id = next_sub_id
121
+ sub = Subscription.new(id: sub_id, session_id: session_id)
122
+ replay.each { |e| sub.enqueue(e) }
123
+ (@subscriptions[session_id] ||= []) << sub
124
+ sub
125
+ end
126
+ end
127
+
128
+ # Build a Sink bound to this host and session — the bridge that
129
+ # accepts ask-runtime event-sink emissions (tool lifecycle) and
130
+ # records them as session events.
131
+ def sink(session_id, trace_id: nil, causation_id: nil)
132
+ Sink.new(host: self, session_id: session_id, trace_id: trace_id, causation_id: causation_id)
133
+ end
134
+
135
+ private
136
+
137
+ def next_sub_id
138
+ @sub_id_counter += 1
139
+ end
140
+
141
+ def publish(event)
142
+ subs = @subscriptions[event.session_id]
143
+ return unless subs
144
+ subs.reject!(&:closed?)
145
+ subs.each { |sub| sub.enqueue(event) }
146
+ end
147
+
148
+ def assert_open!(record)
149
+ case record.status
150
+ when :closed
151
+ raise InvalidTransitionError, "Session #{record.id} is closed"
152
+ when :aborted
153
+ raise InvalidTransitionError, "Session #{record.id} is aborted"
154
+ end
155
+ end
156
+
157
+ def current_state(session_id)
158
+ State.reduce(session_id, @store.events(session_id))
159
+ end
160
+ end
161
+ end
162
+ end