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,589 @@
1
+ # Tep::Presence -- topic-keyed who's-here registry.
2
+ #
3
+ # Battery 3 (Presence). Layers on req.identity (Battery 1) for
4
+ # the principal+delegate split and on the Broadcast pub-sub
5
+ # surface (Battery 2) for diff fan-out -- though in chunk 3.1
6
+ # diff broadcasting isn't wired yet; apps that want presence
7
+ # diffs build them on top of list() snapshots for now.
8
+ #
9
+ # Tracking model: one PresenceEntry per (principal, session,
10
+ # topic). `fd` doubles as the session-id surrogate, so a human
11
+ # with three browser tabs lands as three entries with kind=:human
12
+ # and three distinct fds. An agent acting on behalf of that
13
+ # human gets its own entry with kind=:agent_for and the agent_id
14
+ # populated -- four entries, one principal, one topic. This is
15
+ # the Phoenix.Presence shape extended with the agentic kind/
16
+ # agent_id pair.
17
+ #
18
+ # Local storage is per-process (Tep::APP) for the fast list /
19
+ # count read path. Cross-worker visibility goes through PG --
20
+ # Tep::Presence.enable_pg_mirror writes each track/untrack/
21
+ # set_status as an UPSERT/DELETE; list_global pulls the union.
22
+ # Apps that don't need cross-worker snapshots run single-worker
23
+ # or skip the mirror entirely.
24
+ #
25
+ # Status handling: every entry carries a Tep::PresenceStatus
26
+ # inline (status_state / status_note / status_until on the
27
+ # entry). track() initializes status to :available; apps update
28
+ # via set_status / clear_status. Expiry (status_until) isn't
29
+ # auto-swept in chunk 3.1; the next chunk's diff loop will
30
+ # handle reset-on-expire alongside emit.
31
+ module Tep
32
+ module Presence
33
+ # Track a presence entry. principal_id comes off req.identity;
34
+ # kind is :human or :agent_for depending on the identity's
35
+ # delegation state. fd is the underlying connection's socket
36
+ # (typically a WS-accepted fd). Returns 0 on success.
37
+ #
38
+ # Multiple track() calls for the same (principal, topic, fd)
39
+ # are deduped: the existing entry stays, no second row is
40
+ # created. Apps can call freely from before-filters /
41
+ # reconnect paths without growing the registry.
42
+ def self.track(req, topic, fd)
43
+ ident = req.identity
44
+ if Tep::Presence.find_entry(topic, fd) != nil
45
+ return 0
46
+ end
47
+ kind = :human
48
+ agent_id = ""
49
+ if ident.agent?
50
+ kind = :agent_for
51
+ agent_id = ident.acting_via.agent_id
52
+ end
53
+ entry = Tep::PresenceEntry.new(
54
+ topic, ident.principal_id, kind, agent_id, fd, Time.now.to_i)
55
+ Tep::APP.presence_entries.push(entry)
56
+ Tep::Presence.mirror_insert(entry)
57
+ Tep::Presence.publish_diff("join", entry)
58
+ 0
59
+ end
60
+
61
+ # Drop the entry for (topic, fd). The fd is the unique key
62
+ # within a topic; principal_id isn't needed. Returns 1 if an
63
+ # entry was removed, 0 if none matched. Emits a "leave" diff
64
+ # on the topic's presence channel when removal happens.
65
+ def self.untrack(topic, fd)
66
+ entries = Tep::APP.presence_entries
67
+ i = 0
68
+ while i < entries.length
69
+ if entries[i].topic == topic && entries[i].fd == fd
70
+ e = entries[i]
71
+ entries.delete_at(i)
72
+ Tep::Presence.mirror_delete(topic, fd)
73
+ Tep::Presence.publish_diff("leave", e)
74
+ return 1
75
+ end
76
+ i += 1
77
+ end
78
+ 0
79
+ end
80
+
81
+ # Drop every entry associated with `fd` (across all topics).
82
+ # Used by the WS close hook to clean up everything a
83
+ # connection had tracked. Returns the count dropped. Emits
84
+ # one "leave" diff per dropped entry, on each entry's topic's
85
+ # presence channel.
86
+ def self.untrack_by_fd(fd)
87
+ entries = Tep::APP.presence_entries
88
+ dropped = 0
89
+ i = entries.length - 1
90
+ while i >= 0
91
+ if entries[i].fd == fd
92
+ e = entries[i]
93
+ entries.delete_at(i)
94
+ Tep::Presence.mirror_delete(e.topic, fd)
95
+ Tep::Presence.publish_diff("leave", e)
96
+ dropped += 1
97
+ end
98
+ i -= 1
99
+ end
100
+ dropped
101
+ end
102
+
103
+ # All entries for `topic`. Caller groups by principal_id when
104
+ # they want the Phoenix.Presence-style {principal => [metas]}
105
+ # shape; tep doesn't pre-group because spinel's nested-hash
106
+ # lowering is awkward.
107
+ def self.list(topic)
108
+ result = [Tep::PresenceEntry.new("", "", :human, "", -1, 0)]
109
+ result.delete_at(0)
110
+ entries = Tep::APP.presence_entries
111
+ i = 0
112
+ while i < entries.length
113
+ if entries[i].topic == topic
114
+ result.push(entries[i])
115
+ end
116
+ i += 1
117
+ end
118
+ result
119
+ end
120
+
121
+ # Total entries for `topic` (across all kinds).
122
+ def self.count(topic)
123
+ Tep::Presence.count_filtered(topic, :both)
124
+ end
125
+
126
+ def self.count_humans(topic)
127
+ Tep::Presence.count_filtered(topic, :human)
128
+ end
129
+
130
+ def self.count_agents(topic)
131
+ Tep::Presence.count_filtered(topic, :agent_for)
132
+ end
133
+
134
+ # Internal counting helper: `kind_filter` is :both for all
135
+ # entries, otherwise :human or :agent_for to filter.
136
+ def self.count_filtered(topic, kind_filter)
137
+ entries = Tep::APP.presence_entries
138
+ n = 0
139
+ i = 0
140
+ while i < entries.length
141
+ if entries[i].topic == topic
142
+ if kind_filter == :both
143
+ n += 1
144
+ elsif entries[i].kind == kind_filter
145
+ n += 1
146
+ end
147
+ end
148
+ i += 1
149
+ end
150
+ n
151
+ end
152
+
153
+ # Set the structured status on an existing entry. `state` ∈
154
+ # {:available, :busy, :blocked}; `note` is free text (~140
155
+ # char soft hint); `until_ts` is unix epoch seconds (0 = no
156
+ # identity-level expiry). Returns 1 if the entry was found
157
+ # and updated, 0 otherwise. Emits a "status" diff on the
158
+ # topic's presence channel on update.
159
+ def self.set_status(topic, fd, state, note, until_ts)
160
+ entry = Tep::Presence.find_entry(topic, fd)
161
+ if entry == nil
162
+ return 0
163
+ end
164
+ entry.status_state = state
165
+ entry.status_note = note
166
+ entry.status_until = until_ts
167
+ Tep::Presence.mirror_status(topic, fd, state, note, until_ts)
168
+ Tep::Presence.publish_diff("status", entry)
169
+ 1
170
+ end
171
+
172
+ # Reset an entry's status back to :available / "" / 0.
173
+ def self.clear_status(topic, fd)
174
+ Tep::Presence.set_status(topic, fd, :available, "", 0)
175
+ end
176
+
177
+ # Internal: find the entry matching (topic, fd). Returns nil
178
+ # if no match.
179
+ def self.find_entry(topic, fd)
180
+ entries = Tep::APP.presence_entries
181
+ i = 0
182
+ while i < entries.length
183
+ if entries[i].topic == topic && entries[i].fd == fd
184
+ return entries[i]
185
+ end
186
+ i += 1
187
+ end
188
+ nil
189
+ end
190
+
191
+ # Drop every entry. Used by tests between fixtures and
192
+ # available to apps for graceful-shutdown cleanup. Returns
193
+ # the count dropped. Does NOT emit leave diffs (it's a
194
+ # registry-management op, not a per-connection event).
195
+ def self.clear
196
+ entries = Tep::APP.presence_entries
197
+ n = entries.length
198
+ while entries.length > 0
199
+ entries.delete_at(0)
200
+ end
201
+ n
202
+ end
203
+
204
+ # ---- Diff broadcasting + auto-expiry ----
205
+
206
+ # Compose the Broadcast topic for diff fan-out on a presence
207
+ # topic. WS subscribers register via
208
+ # Tep::Broadcast.subscribe_ws(diff_topic("room:lobby"), ws_fd).
209
+ def self.diff_topic(topic)
210
+ "presence:" + topic
211
+ end
212
+
213
+ # Flat-JSON wire format for a diff event. `kind` is one of
214
+ # "join" / "leave" / "status". Tep::Json's flat-object
215
+ # extractors handle this on the client side (or any
216
+ # JSON-aware peer).
217
+ def self.encode_diff(kind, entry)
218
+ "{" +
219
+ Tep::Json.encode_pair_str("kind", kind) + "," +
220
+ Tep::Json.encode_pair_str("topic", entry.topic) + "," +
221
+ Tep::Json.encode_pair_str("principal", entry.principal_id) + "," +
222
+ Tep::Json.encode_pair_str("ekind", entry.kind.to_s) + "," +
223
+ Tep::Json.encode_pair_str("agent_id", entry.agent_id) + "," +
224
+ Tep::Json.encode_pair_int("fd", entry.fd) + "," +
225
+ Tep::Json.encode_pair_int("since", entry.since) + "," +
226
+ Tep::Json.encode_pair_str("state", entry.status_state.to_s) + "," +
227
+ Tep::Json.encode_pair_str("note", entry.status_note) + "," +
228
+ Tep::Json.encode_pair_int("until_ts", entry.status_until) +
229
+ "}"
230
+ end
231
+
232
+ # Publish a diff via Tep::Broadcast. Subscribers to
233
+ # diff_topic(entry.topic) -- typically WS connections via
234
+ # subscribe_ws -- receive the encoded JSON payload as their
235
+ # next message. Returns the local-match count from publish
236
+ # (cross-worker delivery counts aren't tracked here, same
237
+ # as Broadcast.publish's documented behavior).
238
+ def self.publish_diff(kind, entry)
239
+ payload = Tep::Presence.encode_diff(kind, entry)
240
+ Tep::Broadcast.publish(
241
+ Tep::Presence.diff_topic(entry.topic), payload)
242
+ end
243
+
244
+ # Sweep entries whose status_until has passed: reset to
245
+ # :available / "" / 0 and emit a "status" diff for each.
246
+ # Apps call this periodically (e.g. once per HTTP request,
247
+ # or in a background fiber once Scheduled is reliable).
248
+ # Returns the count of entries reset.
249
+ def self.sweep_expired_status
250
+ entries = Tep::APP.presence_entries
251
+ now = Time.now.to_i
252
+ swept = 0
253
+ i = 0
254
+ while i < entries.length
255
+ e = entries[i]
256
+ if e.status_until > 0 && e.status_until <= now && e.status_state != :available
257
+ e.status_state = :available
258
+ e.status_note = ""
259
+ e.status_until = 0
260
+ Tep::Presence.publish_diff("status", e)
261
+ swept += 1
262
+ end
263
+ i += 1
264
+ end
265
+ swept
266
+ end
267
+
268
+ # ---- PG mirror (cross-worker visibility) ----
269
+ #
270
+ # Opt-in mirror of the local presence registry to a shared PG
271
+ # table. Each worker's track/untrack/set_status writes also
272
+ # touch the table; list_global / count_global read across all
273
+ # workers. The local registry stays the fast read path for
274
+ # per-worker queries (list / count); list_global is for the
275
+ # "who's globally in this room" snapshot that's typically a
276
+ # one-shot UI render.
277
+ #
278
+ # Worker ID is PID + boot epoch second so a same-PID restart
279
+ # doesn't alias a prior worker's stale rows. On
280
+ # disable_pg_mirror (or clean shutdown), this worker's rows
281
+ # get DELETE'd. Crashed workers leave stale rows; the
282
+ # heartbeat + prune_stale_workers pair below handles the
283
+ # garbage-collection.
284
+ #
285
+ # Returns 0 on success, -1 on connect / schema failure.
286
+ def self.enable_pg_mirror(conninfo)
287
+ conn = PG::Connection.new(conninfo)
288
+ if conn.pgh < 0
289
+ return -1
290
+ end
291
+ # exec raises PG::Error on failure now; degrade gracefully
292
+ # (close + return -1) rather than letting it escape the worker.
293
+ begin
294
+ r = conn.exec(Tep::Presence.schema_sql)
295
+ r.clear
296
+ # Heartbeat table for the prune-stale-workers path (#47).
297
+ r = conn.exec(Tep::Presence.worker_schema_sql)
298
+ r.clear
299
+ rescue PG::Error
300
+ conn.finish
301
+ return -1
302
+ end
303
+ Tep::APP.set_presence_pg_conn(conn)
304
+ worker_id = Sock.sphttp_getpid.to_s + "-" + Time.now.to_i.to_s
305
+ Tep::APP.set_presence_pg_worker_id(worker_id)
306
+ Tep::APP.set_presence_pg_enabled(1)
307
+ # Drop any rows from a prior worker that managed to leave
308
+ # stale entries with this same worker_id (unlikely thanks
309
+ # to the boot-epoch suffix, but defensive). Best-effort.
310
+ Tep::Presence.mirror_exec(
311
+ "DELETE FROM tep_presence WHERE worker_id = $1",
312
+ [worker_id])
313
+ # Register this worker's heartbeat row immediately. Apps
314
+ # refresh it periodically via Tep::Presence.heartbeat;
315
+ # prune_stale_workers deletes rows whose heartbeat is stale.
316
+ Tep::Presence.heartbeat
317
+ 0
318
+ end
319
+
320
+ def self.disable_pg_mirror
321
+ if Tep::APP.presence_pg_enabled == 0
322
+ return 0
323
+ end
324
+ # Best-effort cleanup -- swallow PG errors (we're tearing the
325
+ # mirror down regardless) and still finish + disable below.
326
+ begin
327
+ r = Tep::APP.presence_pg_conn.exec_params(
328
+ "DELETE FROM tep_presence WHERE worker_id = $1",
329
+ [Tep::APP.presence_pg_worker_id])
330
+ r.clear
331
+ # Remove the heartbeat row so prune_stale_workers doesn't
332
+ # see this worker as live after we're gone.
333
+ r = Tep::APP.presence_pg_conn.exec_params(
334
+ "DELETE FROM tep_presence_worker WHERE worker_id = $1",
335
+ [Tep::APP.presence_pg_worker_id])
336
+ r.clear
337
+ rescue PG::Error
338
+ # swallow -- shutting the mirror down anyway
339
+ end
340
+ Tep::APP.presence_pg_conn.finish
341
+ Tep::APP.set_presence_pg_enabled(0)
342
+ 0
343
+ end
344
+
345
+ # CREATE TABLE statement, kept here so apps that want to
346
+ # provision the schema separately (migration runners, etc.)
347
+ # can grab the canonical DDL. Idempotent via IF NOT EXISTS.
348
+ def self.schema_sql
349
+ "CREATE TABLE IF NOT EXISTS tep_presence (" +
350
+ "worker_id TEXT NOT NULL, " +
351
+ "topic TEXT NOT NULL, " +
352
+ "fd INTEGER NOT NULL, " +
353
+ "principal_id TEXT NOT NULL, " +
354
+ "kind TEXT NOT NULL, " +
355
+ "agent_id TEXT NOT NULL, " +
356
+ "since_ts BIGINT NOT NULL, " +
357
+ "status_state TEXT NOT NULL, " +
358
+ "status_note TEXT NOT NULL, " +
359
+ "status_until BIGINT NOT NULL, " +
360
+ "PRIMARY KEY (worker_id, topic, fd)" +
361
+ ")"
362
+ end
363
+
364
+ # Heartbeat table -- one row per worker that's mirroring
365
+ # presence right now. Used by prune_stale_workers to identify
366
+ # crashed workers (no heartbeat updates in N seconds) and
367
+ # garbage-collect their orphan tep_presence rows.
368
+ def self.worker_schema_sql
369
+ "CREATE TABLE IF NOT EXISTS tep_presence_worker (" +
370
+ "worker_id TEXT PRIMARY KEY, " +
371
+ "last_seen_ts BIGINT NOT NULL" +
372
+ ")"
373
+ end
374
+
375
+ # Refresh this worker's heartbeat row to the current Unix
376
+ # timestamp. Apps call this periodically (typical: from a
377
+ # before-filter, a Tep::Job tick, or an explicit timer fiber)
378
+ # so prune_stale_workers can tell live workers from crashed
379
+ # ones. No-op when the PG mirror isn't enabled, or when the
380
+ # mirror was opened on a different process and we're the
381
+ # post-fork child (worker_id is empty until enable_pg_mirror
382
+ # runs locally).
383
+ #
384
+ # Returns 1 if the heartbeat row was upserted, 0 if the call
385
+ # short-circuited (mirror disabled or no worker_id).
386
+ def self.heartbeat
387
+ if Tep::APP.presence_pg_enabled == 0
388
+ return 0
389
+ end
390
+ wid = Tep::APP.presence_pg_worker_id
391
+ if wid.length == 0
392
+ return 0
393
+ end
394
+ begin
395
+ r = Tep::APP.presence_pg_conn.exec_params(
396
+ "INSERT INTO tep_presence_worker (worker_id, last_seen_ts) " +
397
+ "VALUES ($1, $2) " +
398
+ "ON CONFLICT (worker_id) DO UPDATE SET " +
399
+ " last_seen_ts = EXCLUDED.last_seen_ts",
400
+ [wid, Time.now.to_i.to_s])
401
+ r.clear
402
+ rescue PG::Error
403
+ return 0
404
+ end
405
+ 1
406
+ end
407
+
408
+ # Prune crashed-worker rows. Deletes:
409
+ # 1. tep_presence_worker rows whose last_seen_ts is older than
410
+ # ttl_seconds (the worker's heartbeat is stale).
411
+ # 2. tep_presence rows whose worker_id has no surviving
412
+ # heartbeat (orphans left by the crashed worker).
413
+ #
414
+ # Apps call this periodically -- the canonical shape is a
415
+ # before-filter on a "/health" route that internal monitoring
416
+ # hits every 30s, or a Tep::Job that fires from a cron-like
417
+ # tick. Returns the number of tep_presence rows deleted.
418
+ #
419
+ # ttl_seconds should be at least 3x the app's typical
420
+ # heartbeat interval so a transient slow response doesn't
421
+ # evict a live worker. Default callers pass 90 (assumes 30s
422
+ # heartbeats).
423
+ def self.prune_stale_workers(ttl_seconds)
424
+ if Tep::APP.presence_pg_enabled == 0
425
+ return 0
426
+ end
427
+ cutoff = Time.now.to_i - ttl_seconds
428
+ conn = Tep::APP.presence_pg_conn
429
+ begin
430
+ # Drop dead heartbeats first; the second DELETE then walks
431
+ # the worker_id space that's still alive.
432
+ r1 = conn.exec_params(
433
+ "DELETE FROM tep_presence_worker WHERE last_seen_ts < $1",
434
+ [cutoff.to_s])
435
+ r1.clear
436
+ # Now drop presence rows whose worker_id isn't in the live
437
+ # heartbeat table. NOT IN handles both crashed-and-pruned
438
+ # workers and workers that never registered (legacy rows
439
+ # from before this prune feature shipped).
440
+ r2 = conn.exec(
441
+ "DELETE FROM tep_presence " +
442
+ "WHERE worker_id NOT IN (SELECT worker_id FROM tep_presence_worker)")
443
+ n = r2.cmd_tuples
444
+ r2.clear
445
+ rescue PG::Error
446
+ return 0
447
+ end
448
+ n
449
+ end
450
+
451
+ # Best-effort mirror write: run an exec_params on the mirror conn
452
+ # and swallow any PG::Error. The PG mirror is advisory -- local
453
+ # presence is authoritative -- so a transient mirror failure must
454
+ # never propagate into the caller's request now that exec raises
455
+ # (matz/spinel#627 + #1041). Always returns 0.
456
+ def self.mirror_exec(sql, params)
457
+ begin
458
+ r = Tep::APP.presence_pg_conn.exec_params(sql, params)
459
+ r.clear
460
+ rescue PG::Error
461
+ # swallow -- advisory mirror, local presence is authoritative
462
+ end
463
+ 0
464
+ end
465
+
466
+ # Mirror a track to PG. Called from track() when the PG
467
+ # mirror is enabled.
468
+ def self.mirror_insert(entry)
469
+ if Tep::APP.presence_pg_enabled == 0
470
+ return 0
471
+ end
472
+ Tep::Presence.mirror_exec(
473
+ "INSERT INTO tep_presence " +
474
+ "(worker_id, topic, fd, principal_id, kind, agent_id, " +
475
+ " since_ts, status_state, status_note, status_until) " +
476
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) " +
477
+ "ON CONFLICT (worker_id, topic, fd) DO UPDATE SET " +
478
+ " principal_id = EXCLUDED.principal_id, " +
479
+ " kind = EXCLUDED.kind, " +
480
+ " agent_id = EXCLUDED.agent_id, " +
481
+ " since_ts = EXCLUDED.since_ts, " +
482
+ " status_state = EXCLUDED.status_state, " +
483
+ " status_note = EXCLUDED.status_note, " +
484
+ " status_until = EXCLUDED.status_until",
485
+ [
486
+ Tep::APP.presence_pg_worker_id,
487
+ entry.topic,
488
+ entry.fd.to_s,
489
+ entry.principal_id,
490
+ entry.kind.to_s,
491
+ entry.agent_id,
492
+ entry.since.to_s,
493
+ entry.status_state.to_s,
494
+ entry.status_note,
495
+ entry.status_until.to_s
496
+ ])
497
+ end
498
+
499
+ # Mirror an untrack to PG.
500
+ def self.mirror_delete(topic, fd)
501
+ if Tep::APP.presence_pg_enabled == 0
502
+ return 0
503
+ end
504
+ Tep::Presence.mirror_exec(
505
+ "DELETE FROM tep_presence " +
506
+ "WHERE worker_id = $1 AND topic = $2 AND fd = $3",
507
+ [Tep::APP.presence_pg_worker_id, topic, fd.to_s])
508
+ end
509
+
510
+ # Mirror a status update.
511
+ def self.mirror_status(topic, fd, state, note, until_ts)
512
+ if Tep::APP.presence_pg_enabled == 0
513
+ return 0
514
+ end
515
+ Tep::Presence.mirror_exec(
516
+ "UPDATE tep_presence " +
517
+ "SET status_state = $4, status_note = $5, status_until = $6 " +
518
+ "WHERE worker_id = $1 AND topic = $2 AND fd = $3",
519
+ [Tep::APP.presence_pg_worker_id, topic, fd.to_s,
520
+ state.to_s, note, until_ts.to_s])
521
+ end
522
+
523
+ # Cross-worker list: SELECT all entries on `topic` regardless
524
+ # of which worker tracked them. Returns Array[PresenceEntry]
525
+ # built from the PG rows. The returned entries are read-only
526
+ # snapshots -- mutating them doesn't write back to PG.
527
+ def self.list_global(topic)
528
+ result = [Tep::PresenceEntry.new("", "", :human, "", -1, 0)]
529
+ result.delete_at(0)
530
+ if Tep::APP.presence_pg_enabled == 0
531
+ return result
532
+ end
533
+ begin
534
+ r = Tep::APP.presence_pg_conn.exec_params(
535
+ "SELECT principal_id, kind, agent_id, fd, since_ts, " +
536
+ " status_state, status_note, status_until " +
537
+ "FROM tep_presence WHERE topic = $1 ORDER BY since_ts",
538
+ [topic])
539
+ rescue PG::Error
540
+ return result
541
+ end
542
+ i = 0
543
+ n = r.ntuples
544
+ while i < n
545
+ kind_sym = :human
546
+ if r.getvalue(i, 1) == "agent_for"
547
+ kind_sym = :agent_for
548
+ end
549
+ state_sym = :available
550
+ sstr = r.getvalue(i, 5)
551
+ if sstr == "busy"
552
+ state_sym = :busy
553
+ elsif sstr == "blocked"
554
+ state_sym = :blocked
555
+ end
556
+ e = Tep::PresenceEntry.new(
557
+ topic,
558
+ r.getvalue(i, 0),
559
+ kind_sym,
560
+ r.getvalue(i, 2),
561
+ r.getvalue(i, 3).to_i,
562
+ r.getvalue(i, 4).to_i)
563
+ e.status_state = state_sym
564
+ e.status_note = r.getvalue(i, 6)
565
+ e.status_until = r.getvalue(i, 7).to_i
566
+ result.push(e)
567
+ i += 1
568
+ end
569
+ r.clear
570
+ result
571
+ end
572
+
573
+ def self.count_global(topic)
574
+ if Tep::APP.presence_pg_enabled == 0
575
+ return 0
576
+ end
577
+ begin
578
+ r = Tep::APP.presence_pg_conn.exec_params(
579
+ "SELECT count(*) FROM tep_presence WHERE topic = $1",
580
+ [topic])
581
+ rescue PG::Error
582
+ return 0
583
+ end
584
+ n = r.getvalue(0, 0).to_i
585
+ r.clear
586
+ n
587
+ end
588
+ end
589
+ end
@@ -0,0 +1,52 @@
1
+ # Tep::PresenceEntry -- one row in the Tep::Presence registry.
2
+ #
3
+ # Represents one (principal, session, topic) tracking, plus the
4
+ # optional structured-status + agent-delegation metadata that
5
+ # makes Presence agent-aware. Multiple entries with the same
6
+ # principal_id under one topic are normal: a human in three
7
+ # browser tabs (kind=:human, three different fds), plus their
8
+ # delegated summarizer-bot (kind=:agent_for, agent_id set,
9
+ # separate fd) -- five entries, one principal.
10
+ #
11
+ # fd is the underlying socket file descriptor (typically the
12
+ # accepted WS socket). It's the session-id surrogate: each WS
13
+ # connection has its own fd, so fd uniquely identifies a session
14
+ # within a worker. The framework's WS close hook calls
15
+ # Tep::Presence.untrack_by_fd(fd) to clear all entries when the
16
+ # connection closes.
17
+ #
18
+ # `since` is unix epoch seconds at track time. Useful for "online
19
+ # for N minutes" UI labels.
20
+ #
21
+ # Status fields encode Tep::PresenceStatus inline (a separate
22
+ # wrapper class would force a nested struct and complicate
23
+ # spinel's PtrArray<PresenceEntry> dispatch). Status defaults to
24
+ # (:available, "", 0) at track time; apps update via
25
+ # Tep::Presence.set_status.
26
+ module Tep
27
+ class PresenceEntry
28
+ attr_reader :topic # String
29
+ attr_reader :principal_id # String, opaque
30
+ attr_reader :kind # Symbol: :human | :agent_for
31
+ attr_reader :agent_id # String, empty when kind == :human
32
+ attr_reader :fd # Integer, session-id surrogate
33
+ attr_reader :since # Integer unix epoch seconds
34
+ # Structured-status fields (see docs/BATTERIES-DESIGN.md +
35
+ # memory presence_status). state ∈ :available | :busy | :blocked.
36
+ attr_accessor :status_state
37
+ attr_accessor :status_note
38
+ attr_accessor :status_until
39
+
40
+ def initialize(topic, principal_id, kind, agent_id, fd, since)
41
+ @topic = topic
42
+ @principal_id = principal_id
43
+ @kind = kind
44
+ @agent_id = agent_id
45
+ @fd = fd
46
+ @since = since
47
+ @status_state = :available
48
+ @status_note = ""
49
+ @status_until = 0
50
+ end
51
+ end
52
+ end