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 +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +220 -0
- data/lib/ask/session/codec.rb +55 -0
- data/lib/ask/session/event.rb +85 -0
- data/lib/ask/session/host.rb +162 -0
- data/lib/ask/session/provider_store.rb +316 -0
- data/lib/ask/session/record.rb +95 -0
- data/lib/ask/session/sink.rb +189 -0
- data/lib/ask/session/state.rb +54 -0
- data/lib/ask/session/store.rb +154 -0
- data/lib/ask/session/subscription.rb +56 -0
- data/lib/ask/session/version.rb +7 -0
- data/lib/ask/session.rb +28 -0
- data/lib/ask-session.rb +3 -0
- metadata +90 -0
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
|