ask-core 0.2.5 → 0.3.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 +4 -4
- data/CHANGELOG.md +39 -0
- data/lib/ask/state.rb +310 -0
- data/lib/ask/version.rb +1 -1
- data/lib/ask.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f85f689da590da523c0fcf5ead1b02469865eaf957720c8965084d439f69f74
|
|
4
|
+
data.tar.gz: dec0a975ed1b82346e98a9d84820e8cad192db0f0e308d97a1dcbdc5303ebee9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4cb0d8b10f05b778fd66717cb54571d486d21cc93ba95334d5ecd3517d8aa4ccacd92b78fa7dfdc26893d89f5871d18593584cf204967f34d64d1545add1efc0
|
|
7
|
+
data.tar.gz: f00616ed87027aedb9eeadc69445078dd203dee9910dcd014c1c89f6461544241bd1b0e08f6a39c30d60c40452b38f529424c09775e098ed6900aad4b9988279
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
## [0.3.0] — 2026-07-21
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Pluggable state abstraction** — `Ask::State::Adapter` defines a unified interface for key-value storage, distributed locking, message queues, and ordered lists. `Ask::State::Memory` provides an in-process, thread-safe implementation backed by Hash.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
store = Ask::State::Memory.new
|
|
9
|
+
|
|
10
|
+
# Key-value with optional TTL
|
|
11
|
+
store.set("key", "value", ttl: 60)
|
|
12
|
+
store.get("key")
|
|
13
|
+
|
|
14
|
+
# Distributed locking
|
|
15
|
+
lock = store.acquire_lock("resource", ttl: 10)
|
|
16
|
+
store.release_lock("resource", lock)
|
|
17
|
+
|
|
18
|
+
# Message queues
|
|
19
|
+
store.enqueue("queue", { task: "check" })
|
|
20
|
+
store.dequeue("queue")
|
|
21
|
+
|
|
22
|
+
# Ordered lists
|
|
23
|
+
store.list_append("list", "item", max_length: 100)
|
|
24
|
+
store.list_range("list", 0, -1)
|
|
25
|
+
store.list_remove("list", "item")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The adapter pattern mirrors `Ask::Provider` — define the contract in ask-core, provide implementations in separate gems. Production backends (Redis, PostgreSQL) can be added by any gem without modifying ask-core.
|
|
29
|
+
|
|
30
|
+
Data types: `Ask::State::Lock` (with `#expired?`), `Ask::State::QueueEntry`.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **ask-agent's `Persistence::Base` now wraps `Ask::State::Adapter`** — session persistence is backed by the unified state interface instead of a standalone abstract class. `Persistence::InMemory` delegates to `Ask::State::Memory`. Backward compatible — no API changes for users.
|
|
35
|
+
|
|
36
|
+
### Tested
|
|
37
|
+
|
|
38
|
+
- 34 new tests for `Ask::State::Adapter` + `Ask::State::Memory`: key-value operations, TTL expiry, thread safety, locking semantics, queue FIFO order, list management, adapter subclassing, and the base class contract.
|
|
39
|
+
|
|
1
40
|
## [0.2.4] — 2026-07-17
|
|
2
41
|
|
|
3
42
|
### Added
|
data/lib/ask/state.rb
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "forwardable"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
# Pluggable state backend for agent sessions, channel providers, and
|
|
8
|
+
# any other ask-rb component that needs durable key-value storage,
|
|
9
|
+
# distributed locking, message queues, or ordered lists.
|
|
10
|
+
#
|
|
11
|
+
# The {State::Adapter} abstract base defines the contract.
|
|
12
|
+
# {State::Memory} provides an in-process implementation backed by Hash.
|
|
13
|
+
# Production apps can provide Redis, PostgreSQL, or other backends by
|
|
14
|
+
# subclassing {State::Adapter}.
|
|
15
|
+
#
|
|
16
|
+
# @example Using the in-memory adapter
|
|
17
|
+
# store = Ask::State::Memory.new
|
|
18
|
+
# store.set("key", "value")
|
|
19
|
+
# store.get("key") # => "value"
|
|
20
|
+
# store.delete("key")
|
|
21
|
+
#
|
|
22
|
+
# @example Acquiring a distributed lock
|
|
23
|
+
# lock = store.acquire_lock("resource-1", ttl: 10)
|
|
24
|
+
# if lock
|
|
25
|
+
# begin
|
|
26
|
+
# # critical section
|
|
27
|
+
# ensure
|
|
28
|
+
# store.release_lock("resource-1", lock)
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @example Using a message queue
|
|
33
|
+
# store.enqueue("queue-name", { task: "check_health" })
|
|
34
|
+
# entry = store.dequeue("queue-name")
|
|
35
|
+
# # => { id: "uuid", value: { task: "check_health" }, enqueued_at: timestamp }
|
|
36
|
+
module State
|
|
37
|
+
Lock = Data.define(:id, :token, :expires_at) do
|
|
38
|
+
def expired?(now = Time.now)
|
|
39
|
+
expires_at && now >= expires_at
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
QueueEntry = Data.define(:id, :value, :enqueued_at)
|
|
44
|
+
|
|
45
|
+
# Abstract base class for state backends.
|
|
46
|
+
# Subclasses must implement all methods.
|
|
47
|
+
class Adapter
|
|
48
|
+
# Key-value storage
|
|
49
|
+
|
|
50
|
+
# @param key [String] the key
|
|
51
|
+
# @return [Object, nil] the stored value, or nil if not found
|
|
52
|
+
def get(key)
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param key [String] the key
|
|
57
|
+
# @param value [Object] the value (must be JSON-serializable)
|
|
58
|
+
# @param ttl [Integer, nil] time-to-live in seconds (nil = no expiry)
|
|
59
|
+
def set(key, value, ttl: nil)
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param key [String] the key
|
|
64
|
+
def delete(key)
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Atomically set a value only if the key does not already exist.
|
|
69
|
+
# @param key [String] the key
|
|
70
|
+
# @param value [Object] the value
|
|
71
|
+
# @return [Boolean] true if the value was set, false if the key already exists
|
|
72
|
+
def set_if_not_exists(key, value, ttl: nil)
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Distributed locking
|
|
77
|
+
|
|
78
|
+
# Acquire a lock for a key. Returns nil if the lock is held by another owner.
|
|
79
|
+
# @param key [String] the resource to lock
|
|
80
|
+
# @param ttl [Integer] lock time-to-live in seconds (default 10)
|
|
81
|
+
# @return [Lock, nil] the lock if acquired, nil if already held
|
|
82
|
+
def acquire_lock(key, ttl: 10)
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Release a lock. Only the lock owner can release it.
|
|
87
|
+
# @param key [String] the resource to unlock
|
|
88
|
+
# @param lock [Lock] the lock returned by {#acquire_lock}
|
|
89
|
+
# @return [Boolean] true if released, false if lock was already expired or not held
|
|
90
|
+
def release_lock(key, lock)
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Message queues
|
|
95
|
+
|
|
96
|
+
# Push an item onto a named queue.
|
|
97
|
+
# @param queue [String] the queue name
|
|
98
|
+
# @param value [Object] the value to enqueue
|
|
99
|
+
# @return [QueueEntry] the enqueued entry
|
|
100
|
+
def enqueue(queue, value)
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Pop the next item from a named queue.
|
|
105
|
+
# @param queue [String] the queue name
|
|
106
|
+
# @return [QueueEntry, nil] the next entry, or nil if the queue is empty
|
|
107
|
+
def dequeue(queue)
|
|
108
|
+
raise NotImplementedError
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param queue [String] the queue name
|
|
112
|
+
# @return [Integer] the number of items in the queue
|
|
113
|
+
def queue_depth(queue)
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Ordered lists
|
|
118
|
+
|
|
119
|
+
# Append a value to an ordered list. Trims to max_length (keeps newest).
|
|
120
|
+
# @param key [String] the list key
|
|
121
|
+
# @param value [Object] the value to append
|
|
122
|
+
# @param max_length [Integer, nil] maximum list length (nil = no limit)
|
|
123
|
+
def list_append(key, value, max_length: nil)
|
|
124
|
+
raise NotImplementedError
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Return a slice of the list.
|
|
128
|
+
# @param key [String] the list key
|
|
129
|
+
# @param start [Integer] starting index (0-based)
|
|
130
|
+
# @param stop [Integer] ending index (inclusive, -1 for all)
|
|
131
|
+
# @return [Array<Object>] the list slice
|
|
132
|
+
def list_range(key, start = 0, stop = -1)
|
|
133
|
+
raise NotImplementedError
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Remove all occurrences of a value from a list.
|
|
137
|
+
# @param key [String] the list key
|
|
138
|
+
# @param value [Object] the value to remove
|
|
139
|
+
# @return [Integer] number of removed elements
|
|
140
|
+
def list_remove(key, value)
|
|
141
|
+
raise NotImplementedError
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Lifecycle
|
|
145
|
+
|
|
146
|
+
# Optional: called when the adapter is no longer needed.
|
|
147
|
+
def close
|
|
148
|
+
# no-op by default
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# In-process state backend backed by a Hash.
|
|
153
|
+
# All operations are thread-safe via a Mutex.
|
|
154
|
+
# Data is not persisted — lost on process exit.
|
|
155
|
+
class Memory < Adapter
|
|
156
|
+
def initialize
|
|
157
|
+
@data = {}
|
|
158
|
+
@locks = {}
|
|
159
|
+
@queues = {}
|
|
160
|
+
@lists = {}
|
|
161
|
+
@mutex = Mutex.new
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# -- key-value --
|
|
165
|
+
|
|
166
|
+
def get(key)
|
|
167
|
+
@mutex.synchronize do
|
|
168
|
+
expiry = @data[key]&.dig(:expires_at)
|
|
169
|
+
return nil if expiry && Time.now >= expiry
|
|
170
|
+
|
|
171
|
+
@data[key]&.dig(:value)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def set(key, value, ttl: nil)
|
|
176
|
+
@mutex.synchronize do
|
|
177
|
+
@data[key] = {
|
|
178
|
+
value: value,
|
|
179
|
+
expires_at: ttl ? Time.now + ttl : nil
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def delete(key)
|
|
185
|
+
@mutex.synchronize { @data.delete(key) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def set_if_not_exists(key, value, ttl: nil)
|
|
189
|
+
@mutex.synchronize do
|
|
190
|
+
if @data.key?(key)
|
|
191
|
+
expiry = @data[key][:expires_at]
|
|
192
|
+
return false if expiry.nil? || Time.now < expiry
|
|
193
|
+
|
|
194
|
+
# Key expired — treat as nonexistent
|
|
195
|
+
@data.delete(key)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
@data[key] = {
|
|
199
|
+
value: value,
|
|
200
|
+
expires_at: ttl ? Time.now + ttl : nil
|
|
201
|
+
}
|
|
202
|
+
true
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# -- locking --
|
|
207
|
+
|
|
208
|
+
def acquire_lock(key, ttl: 10)
|
|
209
|
+
@mutex.synchronize do
|
|
210
|
+
existing = @locks[key]
|
|
211
|
+
if existing.nil? || existing.expired?
|
|
212
|
+
lock = Lock.new(
|
|
213
|
+
id: key,
|
|
214
|
+
token: SecureRandom.hex(16),
|
|
215
|
+
expires_at: Time.now + ttl
|
|
216
|
+
)
|
|
217
|
+
@locks[key] = lock
|
|
218
|
+
lock
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def release_lock(key, lock)
|
|
224
|
+
@mutex.synchronize do
|
|
225
|
+
current = @locks[key]
|
|
226
|
+
if current && current.token == lock.token && !current.expired?
|
|
227
|
+
@locks.delete(key)
|
|
228
|
+
true
|
|
229
|
+
else
|
|
230
|
+
false
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# -- queues --
|
|
236
|
+
|
|
237
|
+
def enqueue(queue, value)
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
@queues[queue] ||= []
|
|
240
|
+
entry = QueueEntry.new(
|
|
241
|
+
id: SecureRandom.uuid,
|
|
242
|
+
value: value,
|
|
243
|
+
enqueued_at: Time.now
|
|
244
|
+
)
|
|
245
|
+
@queues[queue] << entry
|
|
246
|
+
entry
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def dequeue(queue)
|
|
251
|
+
@mutex.synchronize do
|
|
252
|
+
q = @queues[queue]
|
|
253
|
+
return nil unless q&.any?
|
|
254
|
+
|
|
255
|
+
q.shift
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def queue_depth(queue)
|
|
260
|
+
@mutex.synchronize do
|
|
261
|
+
(@queues[queue] || []).length
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# -- lists --
|
|
266
|
+
|
|
267
|
+
def list_append(key, value, max_length: nil)
|
|
268
|
+
@mutex.synchronize do
|
|
269
|
+
@lists[key] ||= []
|
|
270
|
+
@lists[key] << value
|
|
271
|
+
@lists[key].shift if max_length && @lists[key].length > max_length
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def list_range(key, start = 0, stop = -1)
|
|
276
|
+
@mutex.synchronize do
|
|
277
|
+
list = @lists[key] || []
|
|
278
|
+
return list if start == 0 && stop == -1
|
|
279
|
+
|
|
280
|
+
stop = list.length - 1 if stop == -1 || stop >= list.length
|
|
281
|
+
return [] if start > stop
|
|
282
|
+
|
|
283
|
+
list[start..stop] || []
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def list_remove(key, value)
|
|
288
|
+
@mutex.synchronize do
|
|
289
|
+
list = @lists[key]
|
|
290
|
+
return 0 unless list
|
|
291
|
+
|
|
292
|
+
before = list.length
|
|
293
|
+
list.delete(value)
|
|
294
|
+
before - list.length
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# -- lifecycle --
|
|
299
|
+
|
|
300
|
+
def close
|
|
301
|
+
@mutex.synchronize do
|
|
302
|
+
@data.clear
|
|
303
|
+
@locks.clear
|
|
304
|
+
@queues.clear
|
|
305
|
+
@lists.clear
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
data/lib/ask/version.rb
CHANGED
data/lib/ask.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -69,6 +69,7 @@ files:
|
|
|
69
69
|
- lib/ask/models.rb
|
|
70
70
|
- lib/ask/provider.rb
|
|
71
71
|
- lib/ask/result.rb
|
|
72
|
+
- lib/ask/state.rb
|
|
72
73
|
- lib/ask/stream.rb
|
|
73
74
|
- lib/ask/tool_def.rb
|
|
74
75
|
- lib/ask/version.rb
|