tep 0.11.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/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- metadata +264 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
# Tep::Presence: topic-keyed who's-here registry, agent-aware via
|
|
4
|
+
# Tep::Identity. v1 chunk covers storage + track/untrack +
|
|
5
|
+
# list/count + status; diff broadcasting via Tep::Broadcast lands
|
|
6
|
+
# in a follow-up.
|
|
7
|
+
class TestPresence < TepTest
|
|
8
|
+
app_source <<~RB
|
|
9
|
+
require 'sinatra'
|
|
10
|
+
|
|
11
|
+
# Per-request identity is normally set by Tep::Auth's filter
|
|
12
|
+
# off Bearer / SessionCookie / OAuth credentials. These tests
|
|
13
|
+
# don't go through auth -- we build identities inline and
|
|
14
|
+
# poke them onto req.identity in a before-filter keyed off
|
|
15
|
+
# the ?as=<...> query param. Same exercise of Presence's
|
|
16
|
+
# principal_id / kind / agent_id pickup that the real
|
|
17
|
+
# production path takes.
|
|
18
|
+
|
|
19
|
+
before do
|
|
20
|
+
res.headers["Content-Type"] = "text/plain"
|
|
21
|
+
caps = [:read, :write]
|
|
22
|
+
who = params[:as]
|
|
23
|
+
if who == "agent"
|
|
24
|
+
deleg = Tep::AgentDelegation.new(
|
|
25
|
+
"summarizer-bot", 1000, 9999999999, :token)
|
|
26
|
+
req.identity = Tep::Identity.new("user:42", deleg, caps)
|
|
27
|
+
elsif who.length > 0
|
|
28
|
+
# who looks like "user:NN" -- use it as principal directly.
|
|
29
|
+
req.identity = Tep::Identity.new(who, nil, caps)
|
|
30
|
+
end
|
|
31
|
+
# else: req.identity stays at anonymous (set by auth filter).
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
get '/reset' do
|
|
35
|
+
Tep::Presence.clear.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
get '/track' do
|
|
39
|
+
topic = params[:topic]
|
|
40
|
+
fd = params[:fd].to_i
|
|
41
|
+
Tep::Presence.track(req, topic, fd).to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
get '/untrack' do
|
|
45
|
+
topic = params[:topic]
|
|
46
|
+
fd = params[:fd].to_i
|
|
47
|
+
Tep::Presence.untrack(topic, fd).to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
get '/untrack_by_fd' do
|
|
51
|
+
fd = params[:fd].to_i
|
|
52
|
+
Tep::Presence.untrack_by_fd(fd).to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
get '/count' do
|
|
56
|
+
Tep::Presence.count(params[:topic]).to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
get '/count_humans' do
|
|
60
|
+
Tep::Presence.count_humans(params[:topic]).to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
get '/count_agents' do
|
|
64
|
+
Tep::Presence.count_agents(params[:topic]).to_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
get '/list_summary' do
|
|
68
|
+
# Compact serialization for assertion: principal_id|kind|agent_id|fd
|
|
69
|
+
# SEMICOLON-separated (newlines inside heredoc tep app source
|
|
70
|
+
# appear to absorb indentation -- bench the actual cause out
|
|
71
|
+
# of band).
|
|
72
|
+
topic = params[:topic]
|
|
73
|
+
entries = Tep::Presence.list(topic)
|
|
74
|
+
out = ""
|
|
75
|
+
i = 0
|
|
76
|
+
while i < entries.length
|
|
77
|
+
e = entries[i]
|
|
78
|
+
if out.length > 0
|
|
79
|
+
out = out + ";"
|
|
80
|
+
end
|
|
81
|
+
out = out + e.principal_id + "|" + e.kind.to_s + "|" + e.agent_id + "|" + e.fd.to_s
|
|
82
|
+
i += 1
|
|
83
|
+
end
|
|
84
|
+
out
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
get '/set_status' do
|
|
88
|
+
topic = params[:topic]
|
|
89
|
+
fd = params[:fd].to_i
|
|
90
|
+
state = params[:state].to_sym
|
|
91
|
+
note = params[:note]
|
|
92
|
+
ut = params[:until_ts].to_i
|
|
93
|
+
Tep::Presence.set_status(topic, fd, state, note, ut).to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
get '/clear_status' do
|
|
97
|
+
topic = params[:topic]
|
|
98
|
+
fd = params[:fd].to_i
|
|
99
|
+
Tep::Presence.clear_status(topic, fd).to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
get '/status_summary' do
|
|
103
|
+
topic = params[:topic]
|
|
104
|
+
fd = params[:fd].to_i
|
|
105
|
+
e = Tep::Presence.find_entry(topic, fd)
|
|
106
|
+
if e == nil
|
|
107
|
+
""
|
|
108
|
+
else
|
|
109
|
+
e.status_state.to_s + "|" + e.status_note + "|" + e.status_until.to_s
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# ---- diff broadcasting endpoints (chunk 3.2) ----
|
|
114
|
+
|
|
115
|
+
get '/sub_diff' do
|
|
116
|
+
# Subscribe a fake fd to the diff broadcast topic for `topic`.
|
|
117
|
+
# Returns the diff-broadcast subscriber count so tests can
|
|
118
|
+
# assert "track() fanned out to N subscribers."
|
|
119
|
+
topic = params[:topic]
|
|
120
|
+
fd = params[:fd].to_i
|
|
121
|
+
Tep::Broadcast.subscribe(Tep::Presence.diff_topic(topic), fd).to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
get '/diff_subscribers_for' do
|
|
125
|
+
topic = params[:topic]
|
|
126
|
+
Tep::Broadcast.subscribers_for(Tep::Presence.diff_topic(topic)).to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
get '/clear_broadcast' do
|
|
130
|
+
Tep::Broadcast.clear.to_s
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
get '/encode_diff_for' do
|
|
134
|
+
# Build a synthetic entry + encode a "join" diff. Used by
|
|
135
|
+
# the wire-format test.
|
|
136
|
+
topic = params[:topic]
|
|
137
|
+
e = Tep::Presence.find_entry(topic, params[:fd].to_i)
|
|
138
|
+
if e == nil
|
|
139
|
+
""
|
|
140
|
+
else
|
|
141
|
+
Tep::Presence.encode_diff(params[:kind], e)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
get '/sweep_expired' do
|
|
146
|
+
Tep::Presence.sweep_expired_status.to_s
|
|
147
|
+
end
|
|
148
|
+
RB
|
|
149
|
+
|
|
150
|
+
def setup
|
|
151
|
+
super
|
|
152
|
+
get("/reset")
|
|
153
|
+
get("/clear_broadcast")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ---- empty registry ----
|
|
157
|
+
|
|
158
|
+
def test_count_empty
|
|
159
|
+
assert_equal "0", get("/count?topic=room:lobby").body
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ---- track + list ----
|
|
163
|
+
|
|
164
|
+
def test_track_human
|
|
165
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
166
|
+
assert_equal "user:42|human||1", get("/list_summary?topic=room:lobby").body
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def test_track_agent
|
|
170
|
+
get("/track?topic=room:lobby&fd=2&as=agent")
|
|
171
|
+
# The agentic-row format: principal user:42, kind agent_for,
|
|
172
|
+
# agent_id summarizer-bot.
|
|
173
|
+
assert_equal "user:42|agent_for|summarizer-bot|2",
|
|
174
|
+
get("/list_summary?topic=room:lobby").body
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def test_track_multi_session_same_principal
|
|
178
|
+
# Two browser tabs for user:42 + one summarizer-bot delegate
|
|
179
|
+
# for them. List should return all three.
|
|
180
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
181
|
+
get("/track?topic=room:lobby&fd=2&as=user:42")
|
|
182
|
+
get("/track?topic=room:lobby&fd=3&as=agent")
|
|
183
|
+
body = get("/list_summary?topic=room:lobby").body
|
|
184
|
+
rows = body.split(";").sort
|
|
185
|
+
assert_equal 3, rows.length
|
|
186
|
+
assert_includes rows, "user:42|agent_for|summarizer-bot|3"
|
|
187
|
+
assert_includes rows, "user:42|human||1"
|
|
188
|
+
assert_includes rows, "user:42|human||2"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def test_track_dedups_repeat_calls
|
|
192
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
193
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
194
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
195
|
+
assert_equal "1", get("/count?topic=room:lobby").body
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# ---- count_humans / count_agents ----
|
|
199
|
+
|
|
200
|
+
def test_kind_counts
|
|
201
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
202
|
+
get("/track?topic=room:lobby&fd=2&as=user:99")
|
|
203
|
+
get("/track?topic=room:lobby&fd=3&as=agent")
|
|
204
|
+
assert_equal "3", get("/count?topic=room:lobby").body
|
|
205
|
+
assert_equal "2", get("/count_humans?topic=room:lobby").body
|
|
206
|
+
assert_equal "1", get("/count_agents?topic=room:lobby").body
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ---- untrack ----
|
|
210
|
+
|
|
211
|
+
def test_untrack_drops_one
|
|
212
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
213
|
+
get("/track?topic=room:lobby&fd=2&as=user:99")
|
|
214
|
+
res = get("/untrack?topic=room:lobby&fd=1")
|
|
215
|
+
assert_equal "1", res.body
|
|
216
|
+
assert_equal "1", get("/count?topic=room:lobby").body
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def test_untrack_unknown_zero
|
|
220
|
+
res = get("/untrack?topic=never&fd=99")
|
|
221
|
+
assert_equal "0", res.body
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ---- untrack_by_fd (WS-close hook shape) ----
|
|
225
|
+
|
|
226
|
+
def test_untrack_by_fd_drops_across_topics
|
|
227
|
+
# One fd, three topics -- a human in three rooms simultaneously
|
|
228
|
+
# via one connection. Close their connection -> drop all three.
|
|
229
|
+
get("/track?topic=room:a&fd=1&as=user:42")
|
|
230
|
+
get("/track?topic=room:b&fd=1&as=user:42")
|
|
231
|
+
get("/track?topic=room:c&fd=1&as=user:42")
|
|
232
|
+
dropped = get("/untrack_by_fd?fd=1").body.to_i
|
|
233
|
+
assert_equal 3, dropped
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# ---- topic segregation ----
|
|
237
|
+
|
|
238
|
+
def test_topics_dont_cross
|
|
239
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
240
|
+
get("/track?topic=room:other&fd=2&as=user:99")
|
|
241
|
+
assert_equal "1", get("/count?topic=room:lobby").body
|
|
242
|
+
assert_equal "1", get("/count?topic=room:other").body
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ---- structured status ----
|
|
246
|
+
|
|
247
|
+
def test_status_defaults_to_available
|
|
248
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
249
|
+
res = get("/status_summary?topic=room:lobby&fd=1")
|
|
250
|
+
assert_equal "available||0", res.body
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def test_set_status_busy
|
|
254
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
255
|
+
get("/set_status?topic=room:lobby&fd=1&state=busy¬e=working&until_ts=0")
|
|
256
|
+
res = get("/status_summary?topic=room:lobby&fd=1")
|
|
257
|
+
assert_equal "busy|working|0", res.body
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def test_set_status_blocked_with_until
|
|
261
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
262
|
+
get("/set_status?topic=room:lobby&fd=1&state=blocked¬e=Claude API throttled&until_ts=2026200000")
|
|
263
|
+
res = get("/status_summary?topic=room:lobby&fd=1")
|
|
264
|
+
assert_equal "blocked|Claude API throttled|2026200000", res.body
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def test_clear_status_resets
|
|
268
|
+
get("/track?topic=room:lobby&fd=1&as=user:42")
|
|
269
|
+
get("/set_status?topic=room:lobby&fd=1&state=busy¬e=working&until_ts=0")
|
|
270
|
+
get("/clear_status?topic=room:lobby&fd=1")
|
|
271
|
+
res = get("/status_summary?topic=room:lobby&fd=1")
|
|
272
|
+
assert_equal "available||0", res.body
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def test_set_status_unknown_entry_zero
|
|
276
|
+
res = get("/set_status?topic=never&fd=99&state=busy¬e=&until_ts=0")
|
|
277
|
+
assert_equal "0", res.body
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# ---- diff broadcasting (chunk 3.2) ----
|
|
281
|
+
|
|
282
|
+
def test_track_emits_join_diff
|
|
283
|
+
# Subscribe a fake fd to the room:lobby presence diff stream.
|
|
284
|
+
get("/sub_diff?topic=room:lobby&fd=-1")
|
|
285
|
+
assert_equal "1", get("/diff_subscribers_for?topic=room:lobby").body
|
|
286
|
+
# track() should publish a "join" diff to that channel; the
|
|
287
|
+
# subscriber count is 1 so publish matches 1.
|
|
288
|
+
# We can't easily see the bytes (fake fd), but Broadcast does
|
|
289
|
+
# return the matched count. The test endpoint here doesn't
|
|
290
|
+
# expose publish return -- instead, infer from the fact that
|
|
291
|
+
# track returns 0 (success) + that the broadcast topic
|
|
292
|
+
# exists. That's weak; the wire-format test below covers the
|
|
293
|
+
# payload.
|
|
294
|
+
res = get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
295
|
+
assert_equal "0", res.body
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def test_untrack_emits_leave_diff
|
|
299
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
300
|
+
get("/sub_diff?topic=room:lobby&fd=-1")
|
|
301
|
+
# untrack returns 1 (one entry removed).
|
|
302
|
+
assert_equal "1", get("/untrack?topic=room:lobby&fd=10").body
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def test_set_status_emits_status_diff
|
|
306
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
307
|
+
get("/sub_diff?topic=room:lobby&fd=-1")
|
|
308
|
+
res = get("/set_status?topic=room:lobby&fd=10&state=busy¬e=working&until_ts=0")
|
|
309
|
+
assert_equal "1", res.body
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def test_diff_topic_naming
|
|
313
|
+
# diff_topic(topic) = "presence:" + topic. Apps subscribe via
|
|
314
|
+
# the same convention.
|
|
315
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
316
|
+
get("/sub_diff?topic=room:lobby&fd=-1")
|
|
317
|
+
# The diff broadcast lives under "presence:room:lobby".
|
|
318
|
+
assert_equal "1", get("/diff_subscribers_for?topic=room:lobby").body
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def test_encode_diff_wire_format
|
|
322
|
+
# Track a human, then encode a "join" diff for that entry.
|
|
323
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
324
|
+
res = get("/encode_diff_for?topic=room:lobby&fd=10&kind=join")
|
|
325
|
+
# Flat JSON; assert key fields are present.
|
|
326
|
+
body = res.body
|
|
327
|
+
assert_includes body, "\"kind\":\"join\""
|
|
328
|
+
assert_includes body, "\"topic\":\"room:lobby\""
|
|
329
|
+
assert_includes body, "\"principal\":\"user:42\""
|
|
330
|
+
assert_includes body, "\"ekind\":\"human\""
|
|
331
|
+
assert_includes body, "\"agent_id\":\"\""
|
|
332
|
+
assert_includes body, "\"fd\":10"
|
|
333
|
+
assert_includes body, "\"state\":\"available\""
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def test_encode_diff_for_agent
|
|
337
|
+
get("/track?topic=room:lobby&fd=11&as=agent")
|
|
338
|
+
res = get("/encode_diff_for?topic=room:lobby&fd=11&kind=join")
|
|
339
|
+
body = res.body
|
|
340
|
+
assert_includes body, "\"ekind\":\"agent_for\""
|
|
341
|
+
assert_includes body, "\"agent_id\":\"summarizer-bot\""
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# ---- status auto-expiry ----
|
|
345
|
+
|
|
346
|
+
def test_sweep_expired_status_resets_expired_entries
|
|
347
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
348
|
+
# Set an already-expired status.
|
|
349
|
+
get("/set_status?topic=room:lobby&fd=10&state=blocked¬e=API throttled&until_ts=1")
|
|
350
|
+
assert_equal "blocked|API throttled|1", get("/status_summary?topic=room:lobby&fd=10").body
|
|
351
|
+
# Sweep -- should reset back to :available + emit a "status" diff.
|
|
352
|
+
res = get("/sweep_expired").body.to_i
|
|
353
|
+
assert_equal 1, res
|
|
354
|
+
assert_equal "available||0", get("/status_summary?topic=room:lobby&fd=10").body
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def test_sweep_skips_non_expired
|
|
358
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
359
|
+
# Status with until_ts far in the future.
|
|
360
|
+
get("/set_status?topic=room:lobby&fd=10&state=busy¬e=working&until_ts=9999999999")
|
|
361
|
+
res = get("/sweep_expired").body.to_i
|
|
362
|
+
assert_equal 0, res
|
|
363
|
+
# Status unchanged.
|
|
364
|
+
assert_equal "busy|working|9999999999", get("/status_summary?topic=room:lobby&fd=10").body
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def test_sweep_skips_already_available
|
|
368
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
369
|
+
# Default status is :available with until_ts=0; sweep should
|
|
370
|
+
# skip even though until_ts == 0 is a "no-expiry" sentinel.
|
|
371
|
+
res = get("/sweep_expired").body.to_i
|
|
372
|
+
assert_equal 0, res
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
# Tep::Presence PG mirror: cross-worker visibility. Gated on
|
|
4
|
+
# PG_TEST_URL like test_pg / test_broadcast_pg.
|
|
5
|
+
#
|
|
6
|
+
# PG_TEST_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \
|
|
7
|
+
# ruby test/test_presence_pg.rb
|
|
8
|
+
#
|
|
9
|
+
# Test strategy: enable the mirror on the tep app under test,
|
|
10
|
+
# track + set_status + untrack through the tep API, verify the
|
|
11
|
+
# rows landed in the PG table via list_global. Cross-worker
|
|
12
|
+
# behavior is simulated by inserting rows with a different
|
|
13
|
+
# worker_id from outside the tep app (via raw exec_params on
|
|
14
|
+
# the tep app's own conn -- not perfect isolation but exercises
|
|
15
|
+
# the SELECT-across-workers shape).
|
|
16
|
+
class TestPresencePg < TepTest
|
|
17
|
+
PG_URL = ENV["PG_TEST_URL"]
|
|
18
|
+
|
|
19
|
+
def setup
|
|
20
|
+
if PG_URL.nil? || PG_URL.empty?
|
|
21
|
+
skip "PG_TEST_URL not set (e.g. PG_TEST_URL=postgresql:///postgres). " \
|
|
22
|
+
"See test/test_pg.rb header for the docker recipe."
|
|
23
|
+
end
|
|
24
|
+
super
|
|
25
|
+
# Hard reset between cases: drop all rows so test order doesn't matter.
|
|
26
|
+
get("/reset_pg_table")
|
|
27
|
+
get("/reset")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
app_source <<~RB
|
|
31
|
+
require 'sinatra'
|
|
32
|
+
|
|
33
|
+
PG_URL = "#{PG_URL}"
|
|
34
|
+
|
|
35
|
+
on_start do
|
|
36
|
+
Tep::Presence.enable_pg_mirror(PG_URL)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
before do
|
|
40
|
+
res.headers["Content-Type"] = "text/plain"
|
|
41
|
+
who = params[:as]
|
|
42
|
+
caps = [:read, :write]
|
|
43
|
+
if who == "agent"
|
|
44
|
+
deleg = Tep::AgentDelegation.new(
|
|
45
|
+
"summarizer-bot", 1000, 9999999999, :token)
|
|
46
|
+
req.identity = Tep::Identity.new("user:42", deleg, caps)
|
|
47
|
+
elsif who.length > 0
|
|
48
|
+
req.identity = Tep::Identity.new(who, nil, caps)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
get '/reset' do
|
|
53
|
+
Tep::Presence.clear.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
get '/reset_pg_table' do
|
|
57
|
+
# Wipe the whole tep_presence table; reused between tests.
|
|
58
|
+
# Tolerate a not-yet-created table (exec raises now) -> "0".
|
|
59
|
+
c = Tep::APP.presence_pg_conn
|
|
60
|
+
n = 0
|
|
61
|
+
begin
|
|
62
|
+
r = c.exec("DELETE FROM tep_presence")
|
|
63
|
+
n = r.cmd_tuples
|
|
64
|
+
r.clear
|
|
65
|
+
rescue PG::Error
|
|
66
|
+
n = 0
|
|
67
|
+
end
|
|
68
|
+
n.to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
get '/track' do
|
|
72
|
+
topic = params[:topic]
|
|
73
|
+
fd = params[:fd].to_i
|
|
74
|
+
Tep::Presence.track(req, topic, fd).to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
get '/untrack' do
|
|
78
|
+
topic = params[:topic]
|
|
79
|
+
fd = params[:fd].to_i
|
|
80
|
+
Tep::Presence.untrack(topic, fd).to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
get '/set_status' do
|
|
84
|
+
topic = params[:topic]
|
|
85
|
+
fd = params[:fd].to_i
|
|
86
|
+
state = params[:state].to_sym
|
|
87
|
+
note = params[:note]
|
|
88
|
+
ut = params[:until_ts].to_i
|
|
89
|
+
Tep::Presence.set_status(topic, fd, state, note, ut).to_s
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
get '/count_global' do
|
|
93
|
+
Tep::Presence.count_global(params[:topic]).to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
get '/list_global_summary' do
|
|
97
|
+
# Same compact format as test_presence.rb's list_summary;
|
|
98
|
+
# uses list_global instead of list. principal|kind|agent_id|fd
|
|
99
|
+
# SEMICOLON-separated.
|
|
100
|
+
topic = params[:topic]
|
|
101
|
+
entries = Tep::Presence.list_global(topic)
|
|
102
|
+
out = ""
|
|
103
|
+
i = 0
|
|
104
|
+
while i < entries.length
|
|
105
|
+
e = entries[i]
|
|
106
|
+
if out.length > 0
|
|
107
|
+
out = out + ";"
|
|
108
|
+
end
|
|
109
|
+
out = out + e.principal_id + "|" + e.kind.to_s + "|" + e.agent_id + "|" + e.fd.to_s
|
|
110
|
+
i += 1
|
|
111
|
+
end
|
|
112
|
+
out
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
get '/global_status_summary' do
|
|
116
|
+
# Returns the status fields for an entry matching (topic, principal).
|
|
117
|
+
topic = params[:topic]
|
|
118
|
+
principal = params[:principal]
|
|
119
|
+
entries = Tep::Presence.list_global(topic)
|
|
120
|
+
i = 0
|
|
121
|
+
while i < entries.length
|
|
122
|
+
e = entries[i]
|
|
123
|
+
if e.principal_id == principal
|
|
124
|
+
return e.status_state.to_s + "|" + e.status_note + "|" + e.status_until.to_s
|
|
125
|
+
end
|
|
126
|
+
i += 1
|
|
127
|
+
end
|
|
128
|
+
""
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Heartbeat + prune-stale-workers helpers (chunk per #47).
|
|
132
|
+
get '/heartbeat' do
|
|
133
|
+
Tep::Presence.heartbeat.to_s
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
get '/prune_stale_workers' do
|
|
137
|
+
ttl = params[:ttl].to_i
|
|
138
|
+
Tep::Presence.prune_stale_workers(ttl).to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Inject a heartbeat row at an arbitrary past timestamp so
|
|
142
|
+
# the prune test can simulate a stale-then-pruned worker.
|
|
143
|
+
get '/inject_worker_heartbeat' do
|
|
144
|
+
worker = params[:worker]
|
|
145
|
+
ts = params[:ts].to_i
|
|
146
|
+
c = Tep::APP.presence_pg_conn
|
|
147
|
+
r = c.exec_params(
|
|
148
|
+
"INSERT INTO tep_presence_worker (worker_id, last_seen_ts) " +
|
|
149
|
+
"VALUES ($1, $2) " +
|
|
150
|
+
"ON CONFLICT (worker_id) DO UPDATE SET last_seen_ts = EXCLUDED.last_seen_ts",
|
|
151
|
+
[worker, ts.to_s])
|
|
152
|
+
ok = r.ok? ? "1" : "0"
|
|
153
|
+
r.clear
|
|
154
|
+
ok
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
get '/worker_count' do
|
|
158
|
+
c = Tep::APP.presence_pg_conn
|
|
159
|
+
r = c.exec("SELECT count(*) FROM tep_presence_worker")
|
|
160
|
+
n = "0"
|
|
161
|
+
if r.ok? && r.ntuples > 0
|
|
162
|
+
n = r.values[0][0]
|
|
163
|
+
end
|
|
164
|
+
r.clear
|
|
165
|
+
n
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
get '/reset_worker_table' do
|
|
169
|
+
# Tolerate a not-yet-created table (exec raises now) -> "0".
|
|
170
|
+
c = Tep::APP.presence_pg_conn
|
|
171
|
+
n = 0
|
|
172
|
+
begin
|
|
173
|
+
r = c.exec("DELETE FROM tep_presence_worker")
|
|
174
|
+
n = r.cmd_tuples
|
|
175
|
+
r.clear
|
|
176
|
+
rescue PG::Error
|
|
177
|
+
n = 0
|
|
178
|
+
end
|
|
179
|
+
n.to_s
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Simulate a row written by ANOTHER worker (different worker_id)
|
|
183
|
+
# so list_global has cross-worker data to aggregate.
|
|
184
|
+
get '/inject_other_worker_row' do
|
|
185
|
+
topic = params[:topic]
|
|
186
|
+
principal = params[:principal]
|
|
187
|
+
fd = params[:fd].to_i
|
|
188
|
+
worker = params[:worker]
|
|
189
|
+
c = Tep::APP.presence_pg_conn
|
|
190
|
+
r = c.exec_params(
|
|
191
|
+
"INSERT INTO tep_presence (worker_id, topic, fd, principal_id, kind, agent_id, " +
|
|
192
|
+
" since_ts, status_state, status_note, status_until) " +
|
|
193
|
+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
|
194
|
+
[worker, topic, fd.to_s, principal, "human", "",
|
|
195
|
+
"1000", "available", "", "0"])
|
|
196
|
+
ok = r.ok? ? "1" : "0"
|
|
197
|
+
r.clear
|
|
198
|
+
ok
|
|
199
|
+
end
|
|
200
|
+
RB
|
|
201
|
+
|
|
202
|
+
# ---- track mirrors to PG ----
|
|
203
|
+
|
|
204
|
+
def test_track_mirrors_to_pg
|
|
205
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
206
|
+
assert_equal "1", get("/count_global?topic=room:lobby").body
|
|
207
|
+
assert_equal "user:42|human||10",
|
|
208
|
+
get("/list_global_summary?topic=room:lobby").body
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def test_track_agent_mirrors_with_delegation
|
|
212
|
+
get("/track?topic=room:lobby&fd=11&as=agent")
|
|
213
|
+
assert_equal "user:42|agent_for|summarizer-bot|11",
|
|
214
|
+
get("/list_global_summary?topic=room:lobby").body
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# ---- untrack mirrors removal ----
|
|
218
|
+
|
|
219
|
+
def test_untrack_removes_pg_row
|
|
220
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
221
|
+
assert_equal "1", get("/count_global?topic=room:lobby").body
|
|
222
|
+
get("/untrack?topic=room:lobby&fd=10")
|
|
223
|
+
assert_equal "0", get("/count_global?topic=room:lobby").body
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ---- set_status mirrors ----
|
|
227
|
+
|
|
228
|
+
def test_set_status_mirrors_to_pg
|
|
229
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
230
|
+
get("/set_status?topic=room:lobby&fd=10&state=busy¬e=working&until_ts=2026200000")
|
|
231
|
+
res = get("/global_status_summary?topic=room:lobby&principal=user:42")
|
|
232
|
+
assert_equal "busy|working|2026200000", res.body
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# ---- cross-worker aggregation ----
|
|
236
|
+
|
|
237
|
+
def test_list_global_includes_other_worker_rows
|
|
238
|
+
# Track one entry from THIS worker.
|
|
239
|
+
get("/track?topic=room:lobby&fd=10&as=user:42")
|
|
240
|
+
# Simulate two other workers' entries.
|
|
241
|
+
get("/inject_other_worker_row?topic=room:lobby&principal=user:99&fd=5&worker=worker-B")
|
|
242
|
+
get("/inject_other_worker_row?topic=room:lobby&principal=user:100&fd=7&worker=worker-C")
|
|
243
|
+
assert_equal "3", get("/count_global?topic=room:lobby").body
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def test_list_global_segregates_by_topic
|
|
247
|
+
get("/inject_other_worker_row?topic=room:lobby&principal=user:99&fd=5&worker=worker-B")
|
|
248
|
+
get("/inject_other_worker_row?topic=room:other&principal=user:100&fd=6&worker=worker-B")
|
|
249
|
+
assert_equal "1", get("/count_global?topic=room:lobby").body
|
|
250
|
+
assert_equal "1", get("/count_global?topic=room:other").body
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# ---- heartbeat + prune_stale_workers (#47) ----
|
|
254
|
+
|
|
255
|
+
def test_enable_pg_mirror_registers_heartbeat
|
|
256
|
+
# enable_pg_mirror already ran in on_start; its heartbeat
|
|
257
|
+
# row should be present.
|
|
258
|
+
n = get("/worker_count").body.to_i
|
|
259
|
+
assert n >= 1, "expected at least one heartbeat row, got #{n}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def test_heartbeat_is_idempotent
|
|
263
|
+
# Calling heartbeat multiple times shouldn't multiply rows;
|
|
264
|
+
# the upsert keeps it at one per worker_id.
|
|
265
|
+
#
|
|
266
|
+
# Establish this worker's heartbeat row FIRST, then measure: setup
|
|
267
|
+
# resets tep_presence but not tep_presence_worker, so a prior
|
|
268
|
+
# test's reset_worker_table can leave our row absent. Without this
|
|
269
|
+
# priming, n_before is taken before our row exists and the first
|
|
270
|
+
# heartbeat below re-creates it -- a spurious +1 (a pre-existing
|
|
271
|
+
# isolation gap, not a heartbeat bug).
|
|
272
|
+
get("/heartbeat")
|
|
273
|
+
n_before = get("/worker_count").body.to_i
|
|
274
|
+
3.times { get("/heartbeat") }
|
|
275
|
+
n_after = get("/worker_count").body.to_i
|
|
276
|
+
assert_equal n_before, n_after
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def test_prune_drops_stale_worker_and_its_presence_rows
|
|
280
|
+
get("/reset_worker_table")
|
|
281
|
+
# Inject one fresh + one stale worker, each with their own
|
|
282
|
+
# presence row.
|
|
283
|
+
fresh_ts = Time.now.to_i
|
|
284
|
+
stale_ts = fresh_ts - 3600
|
|
285
|
+
get("/inject_worker_heartbeat?worker=worker-fresh&ts=#{fresh_ts}")
|
|
286
|
+
get("/inject_worker_heartbeat?worker=worker-stale&ts=#{stale_ts}")
|
|
287
|
+
get("/inject_other_worker_row?topic=room:prune&principal=user:1&fd=1&worker=worker-fresh")
|
|
288
|
+
get("/inject_other_worker_row?topic=room:prune&principal=user:2&fd=2&worker=worker-stale")
|
|
289
|
+
assert_equal "2", get("/count_global?topic=room:prune").body
|
|
290
|
+
|
|
291
|
+
# Prune with a 60s TTL: stale (3600s old) gets dropped; fresh
|
|
292
|
+
# (just now) stays. count_global drops to 1.
|
|
293
|
+
pruned = get("/prune_stale_workers?ttl=60").body.to_i
|
|
294
|
+
assert pruned >= 1, "expected at least one tep_presence row deleted, got #{pruned}"
|
|
295
|
+
assert_equal "1", get("/count_global?topic=room:prune").body
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def test_prune_preserves_live_worker_rows
|
|
299
|
+
get("/reset_worker_table")
|
|
300
|
+
# Single fresh worker -- prune should leave it alone.
|
|
301
|
+
fresh_ts = Time.now.to_i
|
|
302
|
+
get("/inject_worker_heartbeat?worker=worker-live&ts=#{fresh_ts}")
|
|
303
|
+
get("/inject_other_worker_row?topic=room:keep&principal=user:9&fd=99&worker=worker-live")
|
|
304
|
+
assert_equal "1", get("/count_global?topic=room:keep").body
|
|
305
|
+
pruned = get("/prune_stale_workers?ttl=60").body.to_i
|
|
306
|
+
assert_equal 0, pruned, "expected no rows deleted for live worker, got #{pruned}"
|
|
307
|
+
assert_equal "1", get("/count_global?topic=room:keep").body
|
|
308
|
+
end
|
|
309
|
+
end
|