terret-ws 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/lib/terret/ws/bounded_queue.rb +66 -0
- data/lib/terret/ws/connection.rb +309 -0
- data/lib/terret/ws/endpoint.rb +87 -0
- data/lib/terret/ws/frames.rb +89 -0
- data/lib/terret/ws/service.rb +149 -0
- data/lib/terret/ws.rb +12 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9317837e6392b15e05b940ab03670aff9cb6ad3a9f4566f06c7ab358400c4cec
|
|
4
|
+
data.tar.gz: e575b34d528824fc811d2c7f0297adef5871994dc0490117cc4bf4f4d1c760b6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 42e8fbeca7c9862fce9e637335a15c2120601038709c1966f0895474183a24c1589c85c714a2cdb6faafdb9ad9e1912205859588952ef365518df7e9069913a1
|
|
7
|
+
data.tar.gz: 0e85c16e7aaafaa627d0944d7b09c9b78191495eb43a4ff09892f48a88ff0f4a3011238037ccccfec69a1d3cbc294d138b7d2bd58fdcbcb09da96402a9d4b983
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/notification"
|
|
4
|
+
|
|
5
|
+
module Terret
|
|
6
|
+
module WS
|
|
7
|
+
# Outbound frame queue: non-blocking producer, waiting consumer. push
|
|
8
|
+
# returns false when full instead of blocking, because the producer is
|
|
9
|
+
# session/event dispatch and a slow socket must never stall the agent
|
|
10
|
+
# loop (plan §9.3).
|
|
11
|
+
class BoundedQueue
|
|
12
|
+
def initialize(limit)
|
|
13
|
+
@limit = limit
|
|
14
|
+
@items = []
|
|
15
|
+
@closed = false
|
|
16
|
+
@waiting = Async::Notification.new
|
|
17
|
+
@space = Async::Notification.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def push(item)
|
|
21
|
+
return false if @closed || @items.size >= @limit
|
|
22
|
+
|
|
23
|
+
@items << item
|
|
24
|
+
@waiting.signal
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Blocking push for the connection's own replay fiber: waits for the
|
|
29
|
+
# writer to drain instead of dropping. Dispatch-path pushes must stay
|
|
30
|
+
# non-blocking — only replay may use this. False when closed.
|
|
31
|
+
def wait_push(item)
|
|
32
|
+
loop do
|
|
33
|
+
return false if @closed
|
|
34
|
+
return true if push(item)
|
|
35
|
+
|
|
36
|
+
@space.wait
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Blocks until an item arrives or the queue closes; nil means
|
|
41
|
+
# closed-and-drained.
|
|
42
|
+
def pop
|
|
43
|
+
loop do
|
|
44
|
+
unless @items.empty?
|
|
45
|
+
item = @items.shift
|
|
46
|
+
@space.signal
|
|
47
|
+
return item
|
|
48
|
+
end
|
|
49
|
+
return nil if @closed
|
|
50
|
+
|
|
51
|
+
@waiting.wait
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def clear = @items.clear
|
|
56
|
+
|
|
57
|
+
def closed? = @closed
|
|
58
|
+
|
|
59
|
+
def close
|
|
60
|
+
@closed = true
|
|
61
|
+
@waiting.signal
|
|
62
|
+
@space.signal
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "frames"
|
|
6
|
+
require_relative "bounded_queue"
|
|
7
|
+
|
|
8
|
+
module Terret
|
|
9
|
+
module WS
|
|
10
|
+
# The per-client protocol engine (docs/protocol.md). Transport is
|
|
11
|
+
# injectable exactly like the openrouter adapter's: tests drive it with
|
|
12
|
+
# an in-memory socket; the real endpoint adapts async-websocket to the
|
|
13
|
+
# same io contract — read -> String|nil (nil on close), write(String),
|
|
14
|
+
# close. One Connection serves one agent. Frames land on existing seams;
|
|
15
|
+
# everything outbound is a durable session event serialized as-is.
|
|
16
|
+
class Connection
|
|
17
|
+
# The bearer this client presented. Kept so a token rotation can sweep
|
|
18
|
+
# the connections it just invalidated (Service#reconfigure).
|
|
19
|
+
attr_reader :agent, :token
|
|
20
|
+
|
|
21
|
+
# A set_policy is durable and every future tool call scans what it
|
|
22
|
+
# installs, so the frame is bounded: a client must not be able to write
|
|
23
|
+
# megabytes of patterns into the log.
|
|
24
|
+
MAX_PATTERNS = 128
|
|
25
|
+
MAX_PATTERN_LENGTH = 256
|
|
26
|
+
|
|
27
|
+
def initialize(ctx:, agent:, io:, runner:, resumer: ->(_agent) {}, queue_limit: 256,
|
|
28
|
+
token: nil, replay_limit: nil, replay_gate: nil)
|
|
29
|
+
@ctx = ctx
|
|
30
|
+
@agent = agent
|
|
31
|
+
@token = token
|
|
32
|
+
@io = io
|
|
33
|
+
@runner = runner # ->(agent, text) { start a turn task owned by the server }
|
|
34
|
+
@resumer = resumer # ->(agent) { re-enter the log's open turn, same rooting }
|
|
35
|
+
@sid = agent.session_id
|
|
36
|
+
@queue_limit = queue_limit
|
|
37
|
+
@replay_limit = replay_limit
|
|
38
|
+
@replay_gate = replay_gate
|
|
39
|
+
@queue = BoundedQueue.new(queue_limit)
|
|
40
|
+
@tail = nil
|
|
41
|
+
@live = false
|
|
42
|
+
@floor = 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Serve until the client goes away. A writer task drains the bounded
|
|
46
|
+
# queue so a slow socket never blocks session/event dispatch.
|
|
47
|
+
def run
|
|
48
|
+
Sync do |task|
|
|
49
|
+
writer = task.async do
|
|
50
|
+
while (text = @queue.pop)
|
|
51
|
+
@io.write(text)
|
|
52
|
+
end
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
warn "terret-ws: #{@sid}: writer failed: #{e.class}: #{e.message}"
|
|
55
|
+
ensure
|
|
56
|
+
@io.close
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
hello
|
|
60
|
+
while (text = @io.read)
|
|
61
|
+
begin
|
|
62
|
+
dispatch(text)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
# docs/protocol.md: any unexpected server-side failure while
|
|
65
|
+
# serving this connection surfaces as internal, then closes.
|
|
66
|
+
warn "terret-ws: #{@sid}: dropping connection on dispatch error: #{e.class}: #{e.message}"
|
|
67
|
+
shutdown(code: "internal")
|
|
68
|
+
break
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
ensure
|
|
72
|
+
dispose
|
|
73
|
+
writer&.wait
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Queue an error and stop; the writer drains first so the client sees
|
|
78
|
+
# why it was dropped (superseded, lagged). Idempotent: the tail is
|
|
79
|
+
# disposed so later appends cannot re-enter and wipe the error frame.
|
|
80
|
+
def shutdown(code:)
|
|
81
|
+
@tail&.call
|
|
82
|
+
@tail = nil
|
|
83
|
+
return if @queue.closed?
|
|
84
|
+
|
|
85
|
+
@queue.clear
|
|
86
|
+
@queue.push(Frames.error(code: code))
|
|
87
|
+
@queue.close
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def sessions = @ctx[:sessions]
|
|
93
|
+
|
|
94
|
+
# Precondition: the session is resolved (Sessions#create/resume) before
|
|
95
|
+
# a Connection is built — fetch here never sees an unknown sid.
|
|
96
|
+
def hello
|
|
97
|
+
last = sessions.fetch(@sid).events.last&.seq || -1
|
|
98
|
+
push_frame(Frames.hello(session_id: @sid, last_seq: last))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def dispatch(text)
|
|
102
|
+
frame = Frames.decode(text)
|
|
103
|
+
case frame[:type]
|
|
104
|
+
when "subscribe" then handle_subscribe(frame[:from_seq])
|
|
105
|
+
when "inject" then handle_inject(frame[:text], frame.fetch(:wake, false))
|
|
106
|
+
when "cancel" then handle_cancel(frame[:reason])
|
|
107
|
+
when "approve" then handle_resolution(frame[:call_id], "approved", nil)
|
|
108
|
+
when "deny" then handle_resolution(frame[:call_id], "denied", frame[:reason])
|
|
109
|
+
when "set_model" then handle_set_model(frame[:role], frame[:model])
|
|
110
|
+
when "set_policy" then handle_set_policy(frame[:patterns])
|
|
111
|
+
else
|
|
112
|
+
raise Frames::BadFrame, "#{frame[:type]} is not supported yet"
|
|
113
|
+
end
|
|
114
|
+
rescue Frames::BadFrame => e
|
|
115
|
+
push_frame(Frames.error(code: "bad_frame", message: e.message))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A client that can't even take protocol frames is gone.
|
|
119
|
+
def push_frame(text)
|
|
120
|
+
return if @queue.push(text)
|
|
121
|
+
|
|
122
|
+
shutdown(code: "lagged")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Replay-then-tail with no gap and no duplicate. Live events buffer
|
|
126
|
+
# while the replay reads and flushes; the flush loops until it drains
|
|
127
|
+
# because wait_push yields, and the final empty-check-to-flip is pure
|
|
128
|
+
# array work — nothing can slip between them on one reactor.
|
|
129
|
+
def handle_subscribe(from_seq)
|
|
130
|
+
@tail&.call
|
|
131
|
+
@live = false
|
|
132
|
+
# The replay window may start later than the client asked: a from_seq
|
|
133
|
+
# reaching further back than replay_limit is capped to the newest
|
|
134
|
+
# window, and the client is told (capped_start). The floor tracks the
|
|
135
|
+
# real start so the live tail stays contiguous with what was replayed.
|
|
136
|
+
start_seq = capped_start(from_seq)
|
|
137
|
+
@floor = start_seq
|
|
138
|
+
buffered = []
|
|
139
|
+
@tail = @ctx.on("session/event") do |ev|
|
|
140
|
+
next unless ev.session_id == @sid && ev.seq >= @floor
|
|
141
|
+
|
|
142
|
+
if @live
|
|
143
|
+
push_event(ev)
|
|
144
|
+
elsif buffered.size >= @queue_limit
|
|
145
|
+
# too far behind before the tail even started
|
|
146
|
+
shutdown(code: "lagged")
|
|
147
|
+
else
|
|
148
|
+
buffered << ev
|
|
149
|
+
end
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
# a socket-side failure must never surface into Sessions#append
|
|
152
|
+
warn "terret-ws: #{@sid}: dropping connection on listener error: #{e.class}: #{e.message}"
|
|
153
|
+
shutdown(code: "internal")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
replayed = read_history(start_seq)
|
|
157
|
+
return unless replay(replayed)
|
|
158
|
+
|
|
159
|
+
last = replayed.empty? ? start_seq - 1 : replayed.last.seq
|
|
160
|
+
until buffered.empty?
|
|
161
|
+
batch = buffered.select { |ev| ev.seq > last }.sort_by(&:seq)
|
|
162
|
+
buffered.clear
|
|
163
|
+
return unless replay(batch)
|
|
164
|
+
|
|
165
|
+
last = batch.last.seq unless batch.empty?
|
|
166
|
+
end
|
|
167
|
+
@live = true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Bound a single reconnect's replay. tip comes from the in-memory working
|
|
171
|
+
# set (O(1), no store read), so the cap is decided before any history is
|
|
172
|
+
# touched: a from_seq more than replay_limit events behind the tip is
|
|
173
|
+
# pulled forward to the newest replay_limit window, and the client is told
|
|
174
|
+
# where that window really begins so it does not believe it holds the
|
|
175
|
+
# skipped history. Without a replay_limit the client's from_seq stands.
|
|
176
|
+
def capped_start(from_seq)
|
|
177
|
+
return from_seq unless @replay_limit
|
|
178
|
+
|
|
179
|
+
tip = sessions.fetch(@sid).events.last&.seq || -1
|
|
180
|
+
return from_seq if tip - from_seq + 1 <= @replay_limit
|
|
181
|
+
|
|
182
|
+
start_seq = tip - @replay_limit + 1
|
|
183
|
+
push_frame(Frames.replay_truncated(requested_from_seq: from_seq, from_seq: start_seq))
|
|
184
|
+
start_seq
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Read the replay window under the shared concurrency gate: at most
|
|
188
|
+
# max_concurrent_replays of these run at once, the rest park their fiber
|
|
189
|
+
# (never the reactor) until a slot frees, so a reconnect storm cannot turn
|
|
190
|
+
# into N simultaneous log reads. The gate wraps only the read — the
|
|
191
|
+
# expensive, resource-bound step (plan §9.4) — so a slow client draining
|
|
192
|
+
# its replay never holds a slot and starves other reconnects. Absent a
|
|
193
|
+
# gate the read runs straight through.
|
|
194
|
+
def read_history(from_seq)
|
|
195
|
+
return sessions.read(@sid, from_seq: from_seq) unless @replay_gate
|
|
196
|
+
|
|
197
|
+
@replay_gate.acquire { sessions.read(@sid, from_seq: from_seq) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Replay runs in the connection's own fiber, so it waits for the writer
|
|
201
|
+
# to drain instead of dropping the client — a long log must never look
|
|
202
|
+
# like a slow reader. False when the queue closed mid-replay.
|
|
203
|
+
def replay(events)
|
|
204
|
+
events.each { |ev| return false unless @queue.wait_push(Frames.event(ev)) }
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A wake on an idle agent whose log holds an open turn resumes that
|
|
209
|
+
# turn — the text rides its next step as context/injected — instead of
|
|
210
|
+
# starting a new one. That is the autonomous restart story: after a
|
|
211
|
+
# deploy, the first stimulus picks the turn back up.
|
|
212
|
+
def handle_inject(text, wake)
|
|
213
|
+
if wake && @agent.status == :idle && @ctx[:loop].resumable?(@sid)
|
|
214
|
+
@agent.inject(text)
|
|
215
|
+
@resumer.call(@agent)
|
|
216
|
+
elsif wake && @agent.status == :idle
|
|
217
|
+
@runner.call(@agent, text)
|
|
218
|
+
else
|
|
219
|
+
@agent.inject(text)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def handle_cancel(reason)
|
|
224
|
+
case @agent.status
|
|
225
|
+
# :stopping is a cancel already standing on a turn that has not
|
|
226
|
+
# reached its boundary yet (docs/subagents.md §8). A client that sends
|
|
227
|
+
# a second frame into that window is not cancelling nothing — the turn
|
|
228
|
+
# is still running — so the frame is honored again with the newer
|
|
229
|
+
# reason rather than answered `not_running`.
|
|
230
|
+
when :running, :stopping
|
|
231
|
+
@agent.cancel(reason)
|
|
232
|
+
when :waiting_approval
|
|
233
|
+
# cancel first, THEN deny: the parked fiber unparks into a turn
|
|
234
|
+
# that already knows it is cancelled; each denial is durable
|
|
235
|
+
@agent.cancel(reason)
|
|
236
|
+
# nothing can be parked without the row, but every reference to an
|
|
237
|
+
# optional service is guarded, including the unreachable ones
|
|
238
|
+
if @ctx.service?(:approvals)
|
|
239
|
+
@ctx[:approvals].deny_pending!(@sid, reason: reason || "cancelled")
|
|
240
|
+
end
|
|
241
|
+
else
|
|
242
|
+
push_frame(Frames.error(code: "not_running"))
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# call_id is deliberately not id: it is a foreign key to the tool/call
|
|
247
|
+
# event's id, and the wire-facing name in docs/protocol.md.
|
|
248
|
+
#
|
|
249
|
+
# Approvals are an opt-in row: without it, approve/deny is unsupported.
|
|
250
|
+
# With it, only a call with a standing approval/requested (and no
|
|
251
|
+
# verdict yet) accepts one — anything else answers stale_call and
|
|
252
|
+
# appends nothing, so a double approve or a typo cannot pollute the
|
|
253
|
+
# log. A verdict landing for an idle agent whose turn is still open in
|
|
254
|
+
# the log means no fiber is parked (the process restarted since it
|
|
255
|
+
# parked) — resume the turn.
|
|
256
|
+
def handle_resolution(call_id, verdict, reason)
|
|
257
|
+
unless @ctx.service?(:approvals)
|
|
258
|
+
return push_frame(Frames.error(code: "unsupported",
|
|
259
|
+
message: "no approvals service is mounted"))
|
|
260
|
+
end
|
|
261
|
+
unless @ctx[:approvals].pending?(@sid, call_id)
|
|
262
|
+
return push_frame(Frames.error(code: "stale_call",
|
|
263
|
+
message: "#{call_id} has no pending approval"))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
payload = { call_id: call_id, verdict: verdict }
|
|
267
|
+
payload[:reason] = reason if reason
|
|
268
|
+
sessions.append(@sid, "approval/resolved", payload)
|
|
269
|
+
@resumer.call(@agent) if @agent.status == :idle && @ctx[:loop].resumable?(@sid)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def handle_set_model(role, model)
|
|
273
|
+
@ctx[:llm].set_role(role, model)
|
|
274
|
+
rescue ArgumentError => e
|
|
275
|
+
push_frame(Frames.error(code: "bad_frame", message: e.message))
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def handle_set_policy(patterns)
|
|
279
|
+
unless patterns.is_a?(Array) && patterns.all? { |p| p.is_a?(String) }
|
|
280
|
+
return push_frame(Frames.error(code: "bad_frame",
|
|
281
|
+
message: "patterns must be an array of strings"))
|
|
282
|
+
end
|
|
283
|
+
if patterns.length > MAX_PATTERNS
|
|
284
|
+
return push_frame(Frames.error(code: "bad_frame",
|
|
285
|
+
message: "at most #{MAX_PATTERNS} patterns"))
|
|
286
|
+
end
|
|
287
|
+
if patterns.any? { |p| p.length > MAX_PATTERN_LENGTH }
|
|
288
|
+
return push_frame(Frames.error(code: "bad_frame",
|
|
289
|
+
message: "a pattern is at most #{MAX_PATTERN_LENGTH} characters"))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
Tools::AllowList.update(@ctx, @sid, patterns)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def push_event(ev)
|
|
296
|
+
return if @queue.push(Frames.event(ev))
|
|
297
|
+
|
|
298
|
+
# the client fell too far behind: drop it rather than stall the loop
|
|
299
|
+
shutdown(code: "lagged")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def dispose
|
|
303
|
+
@tail&.call
|
|
304
|
+
@tail = nil
|
|
305
|
+
@queue.close
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/http/server"
|
|
5
|
+
require "async/http/endpoint"
|
|
6
|
+
require "async/websocket/adapters/http"
|
|
7
|
+
require "protocol/http/response"
|
|
8
|
+
require_relative "frames"
|
|
9
|
+
|
|
10
|
+
module Terret
|
|
11
|
+
module WS
|
|
12
|
+
# The real transport: async-websocket bound to GET /agents/{id}/ws.
|
|
13
|
+
# Everything protocol-shaped lives in Connection; this file only adapts
|
|
14
|
+
# a websocket to Connection's io contract and keeps the link alive with
|
|
15
|
+
# pings under load-balancer idle timeouts (plan §9.4).
|
|
16
|
+
class Endpoint
|
|
17
|
+
PATH = %r{\A/agents/([^/]+)/ws\z}
|
|
18
|
+
|
|
19
|
+
def initialize(service, host:, port:)
|
|
20
|
+
@service = service
|
|
21
|
+
@host = host
|
|
22
|
+
@port = port
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
Sync do |task|
|
|
27
|
+
http = Async::HTTP::Endpoint.parse("http://#{@host}:#{@port}")
|
|
28
|
+
app = ->(request) { upgrade(request, task) }
|
|
29
|
+
Async::HTTP::Server.new(app, http).run
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def upgrade(request, root_task)
|
|
36
|
+
m = request.path.match(PATH) or
|
|
37
|
+
return Protocol::HTTP::Response[404, {}, ["not found"]]
|
|
38
|
+
|
|
39
|
+
token = bearer(request)
|
|
40
|
+
response = Async::WebSocket::Adapters::HTTP.open(request) do |ws|
|
|
41
|
+
io = SocketIO.new(ws)
|
|
42
|
+
pinger = root_task.async do
|
|
43
|
+
loop do
|
|
44
|
+
sleep @service.heartbeat
|
|
45
|
+
ws.send_ping("")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
begin
|
|
49
|
+
@service.attach(session_id: m[1], token: token, io: io, runner_task: root_task)
|
|
50
|
+
ensure
|
|
51
|
+
pinger.stop
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
response || Protocol::HTTP::Response[400, {}, ["websocket upgrade required"]]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def bearer(request)
|
|
58
|
+
request.headers["authorization"]&.[](/\ABearer (.+)\z/i, 1)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Adapts Async::WebSocket::Connection to Connection's io contract.
|
|
62
|
+
class SocketIO
|
|
63
|
+
def initialize(ws) = @ws = ws
|
|
64
|
+
|
|
65
|
+
def read
|
|
66
|
+
message = @ws.read
|
|
67
|
+
return nil if message.nil?
|
|
68
|
+
# The wire contract is JSON text frames only (docs/protocol.md). A
|
|
69
|
+
# binary frame degrades to an invalid frame so the client gets a
|
|
70
|
+
# bad_frame answer instead of being silently interpreted as text.
|
|
71
|
+
return "\x00" unless message.is_a?(Protocol::WebSocket::TextMessage)
|
|
72
|
+
|
|
73
|
+
message.to_str
|
|
74
|
+
rescue EOFError, Errno::ECONNRESET, Protocol::WebSocket::ClosedError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write(text)
|
|
79
|
+
@ws.write(text)
|
|
80
|
+
@ws.flush
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def close(*) = @ws.close
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Terret
|
|
7
|
+
module WS
|
|
8
|
+
# Wire codec for docs/protocol.md. Server frames are durable session
|
|
9
|
+
# events serialized as-is, plus hello and error; client frames are the
|
|
10
|
+
# closed §9.2 set. Anything else is BadFrame.
|
|
11
|
+
module Frames
|
|
12
|
+
class BadFrame < StandardError; end
|
|
13
|
+
|
|
14
|
+
PROTO = 1
|
|
15
|
+
|
|
16
|
+
MAX_FRAME_BYTES = 1 << 20 # nothing legitimate on this wire is bigger
|
|
17
|
+
|
|
18
|
+
# required keys per client frame type
|
|
19
|
+
CLIENT = {
|
|
20
|
+
"subscribe" => [:from_seq],
|
|
21
|
+
"inject" => [:text],
|
|
22
|
+
"cancel" => [],
|
|
23
|
+
"approve" => [:call_id],
|
|
24
|
+
"deny" => [:call_id],
|
|
25
|
+
"set_model" => %i[role model],
|
|
26
|
+
"set_policy" => [:patterns]
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def decode(text)
|
|
32
|
+
raise BadFrame, "frame must be a string" unless text.is_a?(String)
|
|
33
|
+
raise BadFrame, "frame exceeds #{MAX_FRAME_BYTES} bytes" if text.bytesize > MAX_FRAME_BYTES
|
|
34
|
+
|
|
35
|
+
h = begin
|
|
36
|
+
JSON.parse(text, symbolize_names: true)
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
raise BadFrame, "frame is not valid JSON"
|
|
39
|
+
end
|
|
40
|
+
raise BadFrame, "frame is not an object" unless h.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
required = CLIENT[h[:type]] or raise BadFrame, "unknown frame type #{h[:type].inspect}"
|
|
43
|
+
missing = required.reject { |k| h.key?(k) }
|
|
44
|
+
raise BadFrame, "#{h[:type]} frame missing #{missing.join(', ')}" unless missing.empty?
|
|
45
|
+
|
|
46
|
+
if h[:type] == "subscribe" && !(h[:from_seq].is_a?(Integer) && h[:from_seq] >= 0)
|
|
47
|
+
raise BadFrame, "from_seq must be a non-negative integer"
|
|
48
|
+
end
|
|
49
|
+
# every other typed field degrades to BadFrame here, so junk can never
|
|
50
|
+
# reach a seam as the wrong type and crash the read loop
|
|
51
|
+
%i[text call_id role model reason].each do |k|
|
|
52
|
+
next unless h.key?(k)
|
|
53
|
+
|
|
54
|
+
raise BadFrame, "#{k} must be a UTF-8 string" unless h[k].is_a?(String) && h[k].valid_encoding?
|
|
55
|
+
end
|
|
56
|
+
if h.key?(:wake) && ![true, false].include?(h[:wake])
|
|
57
|
+
raise BadFrame, "wake must be true or false"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
h
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def event(ev)
|
|
64
|
+
JSON.generate(id: ev.id, session_id: ev.session_id, seq: ev.seq,
|
|
65
|
+
at: ev.at.iso8601(6), type: ev.type, payload: ev.payload)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def hello(session_id:, last_seq:)
|
|
69
|
+
JSON.generate(type: "hello", proto: PROTO, session_id: session_id, last_seq: last_seq)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def error(code:, message: nil)
|
|
73
|
+
h = { type: "error", code: code }
|
|
74
|
+
h[:message] = message if message
|
|
75
|
+
JSON.generate(h)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Honest truncation: a subscribe reaching further back than the server's
|
|
79
|
+
# replay_limit gets only the newest window, and this frame tells the
|
|
80
|
+
# client the seq its replay actually begins at (`from_seq`) versus the one
|
|
81
|
+
# it asked for (`requested_from_seq`), so it never believes it holds the
|
|
82
|
+
# history in between. Sent before that window's first event.
|
|
83
|
+
def replay_truncated(requested_from_seq:, from_seq:)
|
|
84
|
+
JSON.generate(type: "replay_truncated", requested_from_seq: requested_from_seq,
|
|
85
|
+
from_seq: from_seq)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "async/semaphore"
|
|
5
|
+
require_relative "connection"
|
|
6
|
+
|
|
7
|
+
module Terret
|
|
8
|
+
module WS
|
|
9
|
+
# ctx[:ws] — the socket interface plugin. Owns bearer auth, the
|
|
10
|
+
# one-connection-per-agent rule, and turn tasks. Turn tasks are rooted on
|
|
11
|
+
# the server, never on a connection: a dropped client must not cancel a
|
|
12
|
+
# turn (plan §9.3). In v1 the agent id names the session.
|
|
13
|
+
class Service < Hames::Service
|
|
14
|
+
service_key :ws
|
|
15
|
+
inject :sessions, :loop, :llm
|
|
16
|
+
config_schema tokens: { type: Hash, default: {},
|
|
17
|
+
doc: "token => agent-id map authorizing socket connections" },
|
|
18
|
+
queue_limit: { type: Integer, default: 256,
|
|
19
|
+
doc: "max frames buffered per connection before backpressure" },
|
|
20
|
+
heartbeat: { type: Numeric, default: 20, doc: "seconds between server heartbeats" },
|
|
21
|
+
replay_limit: { type: Integer, default: 10_000,
|
|
22
|
+
doc: "max events replayed on one subscribe; a from_seq reaching " \
|
|
23
|
+
"further back gets the newest replay_limit and a replay_truncated frame" },
|
|
24
|
+
max_concurrent_replays: { type: Integer, default: 4,
|
|
25
|
+
doc: "max subscribe replays reading the log at once; " \
|
|
26
|
+
"surplus connections park until a slot frees" }
|
|
27
|
+
|
|
28
|
+
def start(ctx)
|
|
29
|
+
@ctx = ctx
|
|
30
|
+
@tokens = config[:tokens] || {}
|
|
31
|
+
@queue_limit = config[:queue_limit] || 256
|
|
32
|
+
@heartbeat = config[:heartbeat] || 20
|
|
33
|
+
@replay_limit = config[:replay_limit] || 10_000
|
|
34
|
+
# One shared gate across every connection is what makes the cap global;
|
|
35
|
+
# it is created once and its limit adjusted in place (never replaced),
|
|
36
|
+
# or old and new connections would each get their own allowance.
|
|
37
|
+
@replay_gate = Async::Semaphore.new(config[:max_concurrent_replays] || 4)
|
|
38
|
+
@connections = {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reconfigure(config)
|
|
42
|
+
@tokens = config[:tokens] || {}
|
|
43
|
+
@queue_limit = config[:queue_limit] || 256
|
|
44
|
+
@heartbeat = config[:heartbeat] || 20
|
|
45
|
+
@replay_limit = config[:replay_limit] || 10_000
|
|
46
|
+
@replay_gate.limit = config[:max_concurrent_replays] || 4
|
|
47
|
+
revoke_stale_connections
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :heartbeat
|
|
51
|
+
|
|
52
|
+
# Constant-time bearer check, per agent id, before the agent exists.
|
|
53
|
+
def authorized?(session_id, token)
|
|
54
|
+
expected = @tokens[session_id.to_s]
|
|
55
|
+
return false unless expected && token
|
|
56
|
+
|
|
57
|
+
OpenSSL::Digest::SHA256.digest(expected.to_s) ==
|
|
58
|
+
OpenSSL::Digest::SHA256.digest(token.to_s)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Serve one client over io until it closes. runner_task owns the turn
|
|
62
|
+
# tasks so they outlive the connection handler.
|
|
63
|
+
def attach(session_id:, token:, io:, runner_task: Async::Task.current)
|
|
64
|
+
unless authorized?(session_id, token)
|
|
65
|
+
io.write(Frames.error(code: "unauthorized"))
|
|
66
|
+
io.close
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sid = session_id.to_s
|
|
71
|
+
resolve_session(sid)
|
|
72
|
+
begin
|
|
73
|
+
agent = @ctx[:loop].agent("agent-#{sid}") || @ctx[:loop].spawn_agent(session_id: sid)
|
|
74
|
+
rescue AgentExists, AgentCapExceeded => e
|
|
75
|
+
# a registry refusal is this process's business, not the client's
|
|
76
|
+
# fault; it still deserves an answer rather than a dropped socket
|
|
77
|
+
io.write(Frames.error(code: "internal", message: e.message))
|
|
78
|
+
io.close
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
@connections[sid]&.shutdown(code: "superseded")
|
|
82
|
+
conn = Connection.new(ctx: @ctx, agent: agent, io: io,
|
|
83
|
+
runner: runner(runner_task), resumer: resumer(runner_task),
|
|
84
|
+
queue_limit: @queue_limit, token: token,
|
|
85
|
+
replay_limit: @replay_limit, replay_gate: @replay_gate)
|
|
86
|
+
@connections[sid] = conn
|
|
87
|
+
conn.run
|
|
88
|
+
ensure
|
|
89
|
+
@connections.delete(sid) if sid && @connections[sid].equal?(conn)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Blocks serving websocket upgrades. Lazily requires the endpoint so
|
|
93
|
+
# nothing needs async-websocket until something listens.
|
|
94
|
+
def serve(port:, host: "127.0.0.1")
|
|
95
|
+
require_relative "endpoint"
|
|
96
|
+
Endpoint.new(self, host: host, port: port).run
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# A rotation that only locks out the next connection is not a
|
|
102
|
+
# revocation: every live connection whose presented bearer no longer
|
|
103
|
+
# authorizes its session goes, with the same frame it would have got at
|
|
104
|
+
# the door.
|
|
105
|
+
def revoke_stale_connections
|
|
106
|
+
stale = @connections.reject { |sid, conn| authorized?(sid, conn.token) }
|
|
107
|
+
stale.each do |sid, conn|
|
|
108
|
+
conn.shutdown(code: "unauthorized")
|
|
109
|
+
@connections.delete(sid)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve_session(sid)
|
|
114
|
+
sessions = @ctx[:sessions]
|
|
115
|
+
sessions.session_ids.include?(sid) ? sessions.resume(sid) : sessions.create(id: sid)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def runner(task)
|
|
119
|
+
lambda do |agent, text|
|
|
120
|
+
task.async do
|
|
121
|
+
@ctx[:loop].run_turn(agent, text)
|
|
122
|
+
rescue TurnAlreadyRunning
|
|
123
|
+
# two wake frames in one read burst both saw :idle before either
|
|
124
|
+
# task ran; the loser's text rides the winner's next step (or the
|
|
125
|
+
# next turn) via the inbox instead of dropping
|
|
126
|
+
agent.inject(text)
|
|
127
|
+
rescue => e
|
|
128
|
+
warn "terret-ws: turn failed for #{agent.id}: #{e.class}: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Re-enter an open turn (a wake or a verdict arrived for an agent with
|
|
134
|
+
# no live fiber — the process restarted mid-turn). Rooted on the server
|
|
135
|
+
# task for the same reason turns are: a dropped client must not kill it.
|
|
136
|
+
def resumer(task)
|
|
137
|
+
lambda do |agent|
|
|
138
|
+
task.async do
|
|
139
|
+
@ctx[:loop].resume_turn(agent)
|
|
140
|
+
rescue TurnAlreadyRunning
|
|
141
|
+
nil # something else got there first; nothing is lost
|
|
142
|
+
rescue => e
|
|
143
|
+
warn "terret-ws: resume failed for #{agent.id}: #{e.class}: #{e.message}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/terret/ws.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "terret"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
require_relative "../../../terret-core/lib/terret" # monorepo path source
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "ws/frames"
|
|
10
|
+
require_relative "ws/bounded_queue"
|
|
11
|
+
require_relative "ws/connection"
|
|
12
|
+
require_relative "ws/service"
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: terret-ws
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Obie Fernandez
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: terret-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: async-websocket
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.30'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.30'
|
|
40
|
+
description: 'The v1 interface: one connection per agent, durable session events out,
|
|
41
|
+
five client frames in, exact replay-then-tail reconnect on the append-only log,
|
|
42
|
+
bounded-queue backpressure, heartbeat, and bearer auth. Ships as a plugin because
|
|
43
|
+
the primary interface being a plugin is the architecture''s point.'
|
|
44
|
+
email:
|
|
45
|
+
- obiefernandez@gmail.com
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- lib/terret/ws.rb
|
|
51
|
+
- lib/terret/ws/bounded_queue.rb
|
|
52
|
+
- lib/terret/ws/connection.rb
|
|
53
|
+
- lib/terret/ws/endpoint.rb
|
|
54
|
+
- lib/terret/ws/frames.rb
|
|
55
|
+
- lib/terret/ws/service.rb
|
|
56
|
+
homepage: https://terret.org
|
|
57
|
+
licenses:
|
|
58
|
+
- MIT
|
|
59
|
+
metadata:
|
|
60
|
+
homepage_uri: https://terret.org
|
|
61
|
+
source_code_uri: https://github.com/terret-org/terret
|
|
62
|
+
bug_tracker_uri: https://github.com/terret-org/terret/issues
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '4.0'
|
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0'
|
|
77
|
+
requirements: []
|
|
78
|
+
rubygems_version: 4.0.16
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: WebSocket interface for the Terret agent harness
|
|
81
|
+
test_files: []
|