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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. 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&note=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&note=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&note=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&note=&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&note=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&note=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&note=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&note=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