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 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: []