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,265 @@
1
+ # Tep::Broadcast -- in-process pub-sub topic broker.
2
+ #
3
+ # Foundation of the Broadcast battery (Battery 2 in
4
+ # docs/BATTERIES-DESIGN.md). Apps + later batteries (Presence,
5
+ # LiveView) layer on top: WebSocket connections subscribe to
6
+ # topics; publish(topic, payload) writes payload to every
7
+ # subscribed fd.
8
+ #
9
+ # Public API:
10
+ #
11
+ # sub_id = Tep::Broadcast.subscribe(topic, fd)
12
+ # Tep::Broadcast.publish(topic, payload)
13
+ # Tep::Broadcast.unsubscribe(sub_id)
14
+ # Tep::Broadcast.unsubscribe_fd(fd) # drop ALL subs for an fd
15
+ #
16
+ # Subscription model is fd-based rather than block/callback-based
17
+ # (spinel can't reliably round-trip blocks-as-values across module
18
+ # boundaries, see memory [[spinel_widening_dispatch]]). The
19
+ # concrete v1 use case is "deliver to a WS connection" -- the WS
20
+ # layer keeps its accepted-socket fd, calls subscribe, and
21
+ # Tep::Broadcast.publish writes the payload bytes to that fd.
22
+ # Apps that need a different delivery surface (HTTP SSE, log
23
+ # fan-out) use the same subscribe-fd shape with a different fd.
24
+ #
25
+ # Storage scope is per-process: subscriptions live on Tep::APP,
26
+ # which under prefork is per-worker. Cross-worker pub-sub goes
27
+ # through PG LISTEN/NOTIFY (Tep::Broadcast.enable_pg_backend) --
28
+ # subscribers always register fd-local; publish() additionally
29
+ # NOTIFY's the configured channel so peer workers' local
30
+ # subscribers see the message too.
31
+ #
32
+ # `subscribe` returns an opaque subscription id (the registry
33
+ # index at insertion time). Callers can pass it back to
34
+ # `unsubscribe` for a single-sub drop. For WS connections that
35
+ # subscribe to multiple topics, `unsubscribe_fd(fd)` drops every
36
+ # subscription tied to that fd in one call -- the right shape for
37
+ # the WS on-close hook.
38
+ module Tep
39
+ module Broadcast
40
+ # Register a subscription for `fd` on `topic`. Returns an
41
+ # opaque sub_id for later unsubscribe. The fd receives raw
42
+ # bytes on publish -- suits SSE / log fan-out / anything that
43
+ # doesn't need WebSocket framing. For WS connections, prefer
44
+ # subscribe_ws.
45
+ def self.subscribe(topic, fd)
46
+ subs = Tep::APP.broadcast_subs
47
+ sub = Tep::BroadcastSubscription.new(topic, fd, 0)
48
+ subs.push(sub)
49
+ subs.length - 1
50
+ end
51
+
52
+ # WebSocket-bridged variant of subscribe. The fd is expected
53
+ # to be an established WS connection (typically a
54
+ # Tep::WebSocket::Connection's #fd). On publish, payload is
55
+ # wrapped in a WS TEXT frame via Tep::WebSocket::Driver
56
+ # before delivery -- the peer sees a well-formed WS message,
57
+ # not raw bytes that would close the connection.
58
+ #
59
+ # Cleanup is automatic: when the WS connection closes,
60
+ # Tep::WebSocket::Connection.dispatch_close runs the user's
61
+ # on_close handler and then calls unsubscribe_fd(driver.fd),
62
+ # dropping every subscription tied to the closed connection.
63
+ # Apps don't need to add their own unsubscribe; if they do,
64
+ # the second call just finds 0 matches (harmless).
65
+ def self.subscribe_ws(topic, fd)
66
+ subs = Tep::APP.broadcast_subs
67
+ sub = Tep::BroadcastSubscription.new(
68
+ topic, fd, Tep::WebSocket::OPCODE_TEXT)
69
+ subs.push(sub)
70
+ subs.length - 1
71
+ end
72
+
73
+ # Drop the subscription at `sub_id`. Note that ids are
74
+ # registry indexes; subsequent drops shift everything past it
75
+ # downward. For multi-sub drop, prefer `unsubscribe_fd`.
76
+ def self.unsubscribe(sub_id)
77
+ subs = Tep::APP.broadcast_subs
78
+ if sub_id < 0 || sub_id >= subs.length
79
+ return 0
80
+ end
81
+ subs.delete_at(sub_id)
82
+ 0
83
+ end
84
+
85
+ # Drop every subscription whose fd matches. Returns the count
86
+ # dropped. Used by WS on-close to clean up everything a closing
87
+ # connection had subscribed to. Back-to-front so delete_at
88
+ # indices stay valid mid-loop.
89
+ def self.unsubscribe_fd(fd)
90
+ subs = Tep::APP.broadcast_subs
91
+ dropped = 0
92
+ i = subs.length - 1
93
+ while i >= 0
94
+ if subs[i].fd == fd
95
+ subs.delete_at(i)
96
+ dropped += 1
97
+ end
98
+ i -= 1
99
+ end
100
+ dropped
101
+ end
102
+
103
+ # Write `payload` to every subscribed fd for `topic`. Returns
104
+ # the number of subscriptions matched (NOT the number of
105
+ # successful writes -- a closed / bad fd still counts as
106
+ # matched; the underlying sphttp_write_str returns -1 silently
107
+ # on that fd). Apps that need delivery confirmation should
108
+ # track their own ack channel.
109
+ #
110
+ # When the PG backend is enabled (Tep::Broadcast.enable_pg_backend),
111
+ # publish ALSO NOTIFY's the configured channel so other workers
112
+ # subscribed via poll_pg_once can deliver to their local
113
+ # subscribers. Match count returned is the LOCAL match count;
114
+ # remote deliveries are best-effort and not counted here.
115
+ def self.publish(topic, payload)
116
+ matched = Tep::Broadcast.publish_local_only(topic, payload)
117
+ if Tep::APP.broadcast_pg_enabled != 0
118
+ wire = Tep::Broadcast.encode_wire(topic, payload)
119
+ Tep::APP.broadcast_pg_conn.notify(
120
+ Tep::APP.broadcast_pg_channel, wire)
121
+ end
122
+ matched
123
+ end
124
+
125
+ # Total subscription count across all topics. Useful for
126
+ # diagnostics and the v1 test surface.
127
+ def self.subscriber_count
128
+ Tep::APP.broadcast_subs.length
129
+ end
130
+
131
+ # Count of subscribers for one topic. O(n) over the registry;
132
+ # acceptable for v1 (n is typically small per worker).
133
+ def self.subscribers_for(topic)
134
+ subs = Tep::APP.broadcast_subs
135
+ n = 0
136
+ i = 0
137
+ while i < subs.length
138
+ if subs[i].topic == topic
139
+ n += 1
140
+ end
141
+ i += 1
142
+ end
143
+ n
144
+ end
145
+
146
+ # Drop every subscription. Used by tests between fixtures, and
147
+ # available to apps that need to fully reset (e.g. during
148
+ # graceful shutdown). Returns the count dropped.
149
+ def self.clear
150
+ subs = Tep::APP.broadcast_subs
151
+ n = subs.length
152
+ while subs.length > 0
153
+ subs.delete_at(0)
154
+ end
155
+ n
156
+ end
157
+
158
+ # ---- PG backend (cross-worker pub/sub) ----
159
+ #
160
+ # Opens a dedicated PG connection and issues `LISTEN <channel>`.
161
+ # Subsequent publishes NOTIFY this channel too -- other workers
162
+ # subscribed to the same channel can receive the message via
163
+ # poll_pg_once.
164
+ #
165
+ # `conninfo` is the libpq connect string. `channel` must be a
166
+ # safe SQL identifier (e.g. "tep_broadcast") since it lands
167
+ # inside a LISTEN / NOTIFY command unescaped.
168
+ #
169
+ # Returns 0 on success, -1 on connection or LISTEN failure.
170
+ def self.enable_pg_backend(conninfo, channel)
171
+ conn = PG::Connection.new(conninfo)
172
+ if conn.pgh < 0
173
+ return -1
174
+ end
175
+ if conn.listen(channel) < 0
176
+ return -1
177
+ end
178
+ Tep::APP.set_broadcast_pg_conn(conn)
179
+ Tep::APP.set_broadcast_pg_channel(channel)
180
+ Tep::APP.set_broadcast_pg_enabled(1)
181
+ 0
182
+ end
183
+
184
+ def self.disable_pg_backend
185
+ if Tep::APP.broadcast_pg_enabled == 0
186
+ return 0
187
+ end
188
+ Tep::APP.broadcast_pg_conn.unlisten(Tep::APP.broadcast_pg_channel)
189
+ Tep::APP.broadcast_pg_conn.finish
190
+ Tep::APP.set_broadcast_pg_enabled(0)
191
+ 0
192
+ end
193
+
194
+ # Process one notification from the PG channel: parse the wire
195
+ # format, dispatch to local subscribers as if `publish` had
196
+ # been called locally (but WITHOUT re-NOTIFYing -- that would
197
+ # loop). Returns 1 if a notification was processed, 0 on
198
+ # timeout, -1 on connection error or unenabled backend.
199
+ def self.poll_pg_once(timeout_ms)
200
+ if Tep::APP.broadcast_pg_enabled == 0
201
+ return -1
202
+ end
203
+ r = Tep::APP.broadcast_pg_conn.poll_notification(timeout_ms)
204
+ if r != 1
205
+ return r
206
+ end
207
+ wire = Tep::APP.broadcast_pg_conn.last_notify_payload
208
+ Tep::Broadcast.deliver_wire_local(wire)
209
+ 1
210
+ end
211
+
212
+ # Wire format: "<topic_byte_length>:<topic><payload>".
213
+ # Length-prefixed so topics and payloads with arbitrary chars
214
+ # (commas, colons, embedded quotes, newlines) round-trip
215
+ # unambiguously. Encoded by `publish` when the PG backend is
216
+ # enabled; decoded by `deliver_wire_local`.
217
+ def self.encode_wire(topic, payload)
218
+ topic.length.to_s + ":" + topic + payload
219
+ end
220
+
221
+ def self.deliver_wire_local(wire)
222
+ colon = Tep.str_find(wire, ":", 0)
223
+ if colon <= 0
224
+ return -1
225
+ end
226
+ len_str = wire[0, colon]
227
+ tlen = len_str.to_i
228
+ if tlen < 0 || colon + 1 + tlen > wire.length
229
+ return -1
230
+ end
231
+ topic = wire[colon + 1, tlen]
232
+ payload = wire[colon + 1 + tlen, wire.length - colon - 1 - tlen]
233
+ Tep::Broadcast.publish_local_only(topic, payload)
234
+ end
235
+
236
+ # Same fan-out as #publish but skips the PG NOTIFY step. Used
237
+ # internally by poll_pg_once when delivering a cross-worker
238
+ # message that already came in via PG -- re-NOTIFY would cause
239
+ # an infinite loop.
240
+ #
241
+ # Branches on each subscription's `mode`:
242
+ # * mode 0 -> raw bytes via Sock.sphttp_write_str (default,
243
+ # for SSE / log fan-out / non-framed consumers).
244
+ # * mode != 0 -> WebSocket frame via Tep::WebSocket::Driver.send_frame,
245
+ # using the mode value as the WS opcode (1=TEXT, 2=BINARY).
246
+ def self.publish_local_only(topic, payload)
247
+ subs = Tep::APP.broadcast_subs
248
+ matched = 0
249
+ i = 0
250
+ while i < subs.length
251
+ if subs[i].topic == topic
252
+ if subs[i].mode == 0
253
+ Sock.sphttp_write_str(subs[i].fd, payload)
254
+ else
255
+ Tep::WebSocket::Driver.send_frame(
256
+ subs[i].fd, subs[i].mode, payload)
257
+ end
258
+ matched += 1
259
+ end
260
+ i += 1
261
+ end
262
+ matched
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,42 @@
1
+ # Tep::BroadcastSubscription -- one entry in the Tep::Broadcast
2
+ # subscriber registry. Pairs a topic name with an output fd. When a
3
+ # publish matches the topic, the fd gets the payload bytes via
4
+ # Sock.sphttp_write_str.
5
+ #
6
+ # fd is just an integer file descriptor: typically a WebSocket
7
+ # connection's accepted socket fd, but the registry doesn't care
8
+ # about the protocol on top -- it'll write to any open fd. Apps
9
+ # integrating with WS (via Tep::WebSocket) subscribe their
10
+ # connection fds; non-WS use cases (server-sent events, log
11
+ # fan-out, etc.) work the same way.
12
+ #
13
+ # Each subscription lives in a single worker's registry. Cross-
14
+ # worker pub-sub goes through PG LISTEN/NOTIFY (see
15
+ # Tep::Broadcast.enable_pg_backend) which fans publishes out
16
+ # without moving subscription state; subscribers always register
17
+ # fd-local. See docs/BATTERIES-DESIGN.md for the broader Broadcast
18
+ # battery design.
19
+ module Tep
20
+ class BroadcastSubscription
21
+ attr_reader :topic # String
22
+ attr_reader :fd # Integer file descriptor
23
+ # Delivery mode controls how Tep::Broadcast.publish writes
24
+ # `payload` to `fd`:
25
+ #
26
+ # 0 = raw bytes (Sock.sphttp_write_str). The default; suits
27
+ # SSE / log fan-out / anything that doesn't need framing.
28
+ # 1 = WebSocket TEXT frame (Tep::WebSocket::OPCODE_TEXT).
29
+ # 2 = WebSocket BINARY frame (Tep::WebSocket::OPCODE_BINARY).
30
+ #
31
+ # Modes 1 and 2 route through Tep::WebSocket::Driver.send_frame,
32
+ # so payloads land as proper WS frames the peer will accept.
33
+ # Apps register mode-1 subscriptions via subscribe_ws.
34
+ attr_reader :mode
35
+
36
+ def initialize(topic, fd, mode)
37
+ @topic = topic
38
+ @fd = fd
39
+ @mode = mode
40
+ end
41
+ end
42
+ end
data/lib/tep/cache.rb ADDED
@@ -0,0 +1,49 @@
1
+ # Tep::Cache -- HTTP conditional-GET evaluation (issue #152).
2
+ #
3
+ # A response opts in by setting a validator (res.etag / res.last_modified).
4
+ # The server then short-circuits to 304 Not Modified (no body) when the
5
+ # request carries a matching precondition, so the client reuses its
6
+ # cached copy. Responses that set no validator are unaffected.
7
+ module Tep
8
+ module Cache
9
+ # True iff `req`'s precondition says the client's cached copy of
10
+ # `res` is still fresh (=> answer 304). Safe methods (GET/HEAD) only.
11
+ def self.not_modified?(req, res)
12
+ v = req.verb
13
+ if v != "GET" && v != "HEAD"
14
+ return false
15
+ end
16
+
17
+ # ETag / If-None-Match. `*` matches anything; otherwise the quoted
18
+ # tag is matched as a substring so a comma-separated list of tags
19
+ # in If-None-Match works.
20
+ etag = res.headers["ETag"]
21
+ if etag.length > 0
22
+ inm = req.headers["if-none-match"]
23
+ if inm.length > 0
24
+ if inm == "*"
25
+ return true
26
+ end
27
+ if Tep.str_find(inm, etag, 0) >= 0
28
+ return true
29
+ end
30
+ end
31
+ end
32
+
33
+ # Last-Modified / If-Modified-Since: fresh if our copy is no newer
34
+ # than the client's cached date.
35
+ lm = res.lastmod_epoch
36
+ if lm > 0
37
+ ims = req.headers["if-modified-since"]
38
+ if ims.length > 0
39
+ ims_epoch = Sock.sphttp_parse_http_date(ims)
40
+ if ims_epoch >= 0 && lm <= ims_epoch
41
+ return true
42
+ end
43
+ end
44
+ end
45
+
46
+ false
47
+ end
48
+ end
49
+ end
data/lib/tep/events.rb ADDED
@@ -0,0 +1,257 @@
1
+ # Tep::Events -- toy/v1 event-stream emitter.
2
+ #
3
+ # Appends newline-delimited JSON (JSONL) in the toy/v1 envelope
4
+ # (`docs/events-schema.md` in the toy project): one `run_start` at
5
+ # boot, one serving event (`kind:"eval"`, `phase:"serve"`,
6
+ # `name:"request"`) per served request, one `run_end` at shutdown.
7
+ # The serving stream is structurally indistinguishable from a
8
+ # training stream, so a single downstream ingest (the research-lab
9
+ # orchestrator) consumes both.
10
+ #
11
+ # Standalone on purpose: any tep handler can emit (the chatbot's
12
+ # existing OpenAI-compat endpoint, a future Tep::Llm::OpenAI::Server,
13
+ # a Tep::Proxy gateway via on_stream_end). The full server battery
14
+ # (Battery 7) builds on this rather than reimplementing it.
15
+ #
16
+ # EVENTS = Tep::Events.new(ENV["EVENTS_JSONL"]) # "" => disabled
17
+ # EVENTS.run_start("gx10", "cpu", "smollm2-135m",
18
+ # "/srv/models/smollm2-135m.gguf",
19
+ # "{\"server\":\"tep\",\"cap\":\"infer\"}")
20
+ # # ... per request, after generation completes:
21
+ # EVENTS.inference("smollm2-135m", 12, 8, 87000,
22
+ # "{\"request_id\":\"cmpl-abc\",\"principal_id\":\"user:42\"," +
23
+ # "\"sampling\":{\"temperature\":0.7,\"max_tokens\":256}}")
24
+ # # ... at shutdown:
25
+ # EVENTS.run_end("ok")
26
+ #
27
+ # Schema (supersedes #79): per-request serving telemetry is
28
+ # `kind:"eval", phase:"serve", name:"request"`. The original #79
29
+ # decision used a distinct `kind:"inference"` to avoid overloading
30
+ # toy/v1's `eval` (held-out training evaluation: loss/ppl/samples);
31
+ # that disambiguator moved onto `phase`/`name` instead -- a served
32
+ # completion is `eval`+`phase:"serve"`+`name:"request"`, training eval
33
+ # is `eval`+`phase:"eval"`, so tao keys on the (kind, phase, name)
34
+ # triple rather than `kind` alone. `inference` is retired. See
35
+ # `#inference` below for the emitted shape.
36
+ #
37
+ # Integer-only number fields by design (a tep choice, NOT a spinel
38
+ # constraint -- spinel supports Float fully; this module deliberately
39
+ # avoids it for serving telemetry):
40
+ # * `t` is integer seconds since run_start (a JSON number; consumers
41
+ # reading it as float get N.0). Sub-second ordering isn't needed
42
+ # for serving telemetry -- per-request latency rides in wall_us.
43
+ # * `wall_us` (microsecond latency) is caller-measured + passed in
44
+ # as an int.
45
+ # * Floats that the schema does carry (sampling.temperature) live in
46
+ # the caller-built `extra` JSON string, which tep emits verbatim.
47
+ # Apps that want native Float support in extra can build their
48
+ # own JSON encoder around the float values.
49
+ #
50
+ # `started_at` / `ended_at` are ISO-8601 UTC via Sock.sphttp_iso8601_utc
51
+ # (spinel's Time.now exposes only integer epoch seconds).
52
+ module Tep
53
+ class Events
54
+ def initialize(path)
55
+ @path = path # "" disables emission (zero I/O, zero alloc)
56
+ @run_started = 0 # epoch seconds at run_start; basis for relative t
57
+ @req_count = 0
58
+ @err_count = 0
59
+ @tok_out = 0
60
+ end
61
+
62
+ # True when a non-empty path was configured. Apps that build the
63
+ # emitter unconditionally can cheaply skip work when disabled.
64
+ def enabled?
65
+ @path.length > 0
66
+ end
67
+
68
+ # Emit `run_start` once, before any request. Establishes the
69
+ # relative-t origin even when emission is disabled (so a later
70
+ # enable mid-run wouldn't be needed; cheap either way). host /
71
+ # backend_kind / model_name / model_path are plain strings;
72
+ # config_json is a caller-built JSON object emitted verbatim.
73
+ def run_start(host, backend_kind, model_name, model_path, config_json)
74
+ @run_started = Time.now.to_i
75
+ if @path.length == 0
76
+ return 0
77
+ end
78
+ started = Sock.sphttp_iso8601_utc(@run_started)
79
+ # toy/v1 says host is {name, os, arch} (docs/events-schema.md);
80
+ # was a bare string before #115. os + arch come from uname() via
81
+ # Sock.sphttp_os_kind / sphttp_arch_kind.
82
+ line = "{" +
83
+ Json.encode_pair_str("kind", "run_start") + "," +
84
+ Json.encode_pair_str("schema", "toy/v1") + "," +
85
+ Json.encode_pair_int("t", 0) + "," +
86
+ Json.encode_pair_str("started_at", started) + "," +
87
+ "\"host\":{" +
88
+ Json.encode_pair_str("name", host) + "," +
89
+ Json.encode_pair_str("os", Sock.sphttp_os_kind) + "," +
90
+ Json.encode_pair_str("arch", Sock.sphttp_arch_kind) +
91
+ "}," +
92
+ "\"backend\":{" + Json.encode_pair_str("kind", backend_kind) + "}," +
93
+ "\"model\":{" +
94
+ Json.encode_pair_str("name", model_name) + "," +
95
+ Json.encode_pair_str("path", model_path) +
96
+ "}," +
97
+ "\"config\":" + config_json +
98
+ "}"
99
+ append_line(line)
100
+ end
101
+
102
+ # Emit one inference-time telemetry event in the toy/v1 spec
103
+ # shape (#136): kind:"eval", phase:"serve", name:"request",
104
+ # with model + token counts + latency_us nested inside `extra`
105
+ # alongside whatever the caller passed in extra_json. The
106
+ # producer-facing API stays the same (callers pass
107
+ # prompt_tokens, completion_tokens, wall_us); we rename
108
+ # wall_us -> latency_us at the wire level.
109
+ #
110
+ # extra_json is a caller-built JSON object ("{}" if none)
111
+ # carrying sampling / request_id / principal_id. We strip its
112
+ # outer braces and merge with the spec's per-completion fields
113
+ # to produce the final extra object.
114
+ def inference(model, prompt_tokens, completion_tokens, wall_us, extra_json)
115
+ @req_count = @req_count + 1
116
+ @tok_out = @tok_out + completion_tokens
117
+ if @path.length == 0
118
+ return 0
119
+ end
120
+ # Build the merged extra: spec fields first, then caller's
121
+ # fields appended (if non-empty).
122
+ extra = "{" +
123
+ Json.encode_pair_str("model", model) + "," +
124
+ Json.encode_pair_int("prompt_tokens", prompt_tokens) + "," +
125
+ Json.encode_pair_int("completion_tokens", completion_tokens) + "," +
126
+ Json.encode_pair_int("latency_us", wall_us)
127
+ caller_inner = ""
128
+ if extra_json.length > 2
129
+ # Strip the outer braces -- "{...}" -> "...".
130
+ caller_inner = extra_json[1, extra_json.length - 2]
131
+ end
132
+ if caller_inner.length > 0
133
+ extra = extra + "," + caller_inner
134
+ end
135
+ extra = extra + "}"
136
+ line = "{" +
137
+ Json.encode_pair_str("kind", "eval") + "," +
138
+ Json.encode_pair_str("phase", "serve") + "," +
139
+ Json.encode_pair_int("t", rel_t) + "," +
140
+ Json.encode_pair_str("name", "request") + "," +
141
+ "\"extra\":" + extra +
142
+ "}"
143
+ append_line(line)
144
+ end
145
+
146
+ # Count one server-side error (surfaced in run_end.stats.errors).
147
+ # Separate from emission so the counter advances even when
148
+ # emission is disabled.
149
+ def record_error
150
+ @err_count = @err_count + 1
151
+ 0
152
+ end
153
+
154
+ # Emit `run_end` once at shutdown using LOCAL counters. reason is
155
+ # "completed" (clean) or "errored" (uncaught failure) -- per
156
+ # toy/v1, quality verdicts on the run are downstream decisions,
157
+ # not encoded here. Used for single-process / workers=1 deployments
158
+ # where the writer is the same process that handled the inferences.
159
+ # For workers>1, see run_end_aggregated below.
160
+ def run_end(reason)
161
+ if @path.length == 0
162
+ return 0
163
+ end
164
+ ended = Sock.sphttp_iso8601_utc(Time.now.to_i)
165
+ line = "{" +
166
+ Json.encode_pair_str("kind", "run_end") + "," +
167
+ Json.encode_pair_int("t", rel_t) + "," +
168
+ Json.encode_pair_str("ended_at", ended) + "," +
169
+ Json.encode_pair_str("reason", reason) + "," +
170
+ "\"stats\":{" +
171
+ Json.encode_pair_int("requests", @req_count) + "," +
172
+ Json.encode_pair_int("errors", @err_count) + "," +
173
+ Json.encode_pair_int("tokens_out", @tok_out) +
174
+ "}" +
175
+ "}"
176
+ append_line(line)
177
+ end
178
+
179
+ # Cross-worker run_end: re-read the JSONL + sum inference events
180
+ # so the emitted stats cover every worker's contribution, then
181
+ # emit ONE run_end with aggregated counters. Used by Tep.on_shutdown
182
+ # in the prefork parent (workers>1) -- worker children stop calling
183
+ # run_end at all; only the parent emits, after all workers have
184
+ # exited. Avoids cross-worker IPC entirely.
185
+ def run_end_aggregated(reason)
186
+ if @path.length == 0
187
+ return 0
188
+ end
189
+ reqs = 0
190
+ toks = 0
191
+ # errors aren't yet event-encoded (record_error only bumps a
192
+ # local counter), so cross-worker errors aren't visible here.
193
+ # If a future chunk emits "error" events, sum them too. For
194
+ # now: 0 in aggregated mode.
195
+ errs = 0
196
+ content = File.read(@path)
197
+ lines = content.split("\n")
198
+ i = 0
199
+ while i < lines.length
200
+ line_s = lines[i]
201
+ # #136: inference events are kind:"eval" + phase:"serve" +
202
+ # name:"request". Match the joint shape to avoid counting
203
+ # future non-request eval events (e.g. training-time eval).
204
+ if Tep.str_find(line_s, "\"kind\":\"eval\"", 0) >= 0 &&
205
+ Tep.str_find(line_s, "\"name\":\"request\"", 0) >= 0
206
+ reqs += 1
207
+ # completion_tokens now lives nested inside the `extra`
208
+ # object. Tep::Json.find_value_start walks only the
209
+ # top-level keys (it skips over nested objects), so we
210
+ # have to extract extra first, then get_int within it.
211
+ extra_pos = Json.find_value_start(line_s, "extra")
212
+ if extra_pos >= 0
213
+ obj_end = Json.skip_container(line_s, extra_pos)
214
+ extra_obj = line_s[extra_pos, obj_end - extra_pos]
215
+ toks += Json.get_int(extra_obj, "completion_tokens")
216
+ end
217
+ end
218
+ i += 1
219
+ end
220
+ ended = Sock.sphttp_iso8601_utc(Time.now.to_i)
221
+ out = "{" +
222
+ Json.encode_pair_str("kind", "run_end") + "," +
223
+ Json.encode_pair_int("t", rel_t) + "," +
224
+ Json.encode_pair_str("ended_at", ended) + "," +
225
+ Json.encode_pair_str("reason", reason) + "," +
226
+ "\"stats\":{" +
227
+ Json.encode_pair_int("requests", reqs) + "," +
228
+ Json.encode_pair_int("errors", errs) + "," +
229
+ Json.encode_pair_int("tokens_out", toks) +
230
+ "}" +
231
+ "}"
232
+ append_line(out)
233
+ end
234
+
235
+ # Seconds since run_start, clamped at 0 (a clock that goes
236
+ # backwards, or events before run_start, read as t=0).
237
+ def rel_t
238
+ d = Time.now.to_i - @run_started
239
+ if d < 0
240
+ d = 0
241
+ end
242
+ d
243
+ end
244
+
245
+ # Append one JSON line. Best-effort, append mode -- mirrors
246
+ # Tep::Logger's file sink. Telemetry must never fail a request, so
247
+ # a malformed/unwritable path degrades to a dropped line rather
248
+ # than a raised error reaching the handler. Callers gate on a
249
+ # non-empty @path before reaching here.
250
+ def append_line(line)
251
+ File.open(@path, "a") do |f|
252
+ f.puts(line)
253
+ end
254
+ 0
255
+ end
256
+ end
257
+ end
data/lib/tep/filter.rb ADDED
@@ -0,0 +1,21 @@
1
+ # Tep::Filter -- before/after hooks. Override #before(req, res) and/or
2
+ # #after(req, res). The default base methods are non-empty (they touch
3
+ # their parameters) so Spinel correctly registers them as the dispatch
4
+ # fallback; an empty base method body confuses the codegen and causes
5
+ # overrides to be silently dropped.
6
+ #
7
+ # class TimerFilter < Tep::Filter
8
+ # def after(req, res); res.headers["X-Took"] = "ok"; end
9
+ # end
10
+ # Tep.before TimerFilter.new
11
+ module Tep
12
+ class Filter
13
+ def before(req, res)
14
+ 0 # explicit no-op return; non-empty body keeps spinel happy
15
+ end
16
+
17
+ def after(req, res)
18
+ 0
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,35 @@
1
+ # Tep::Handler -- subclass and override #handle(req, res). Return a
2
+ # string to set the body, or mutate `res` directly.
3
+ #
4
+ # Sinatra-style block syntax (`get '/' do ... end`) is supported via
5
+ # the `bin/tep` build-time translator, which rewrites the block body
6
+ # textually and wraps each block in a Handler subclass.
7
+ #
8
+ # Regex routes (`get %r{...} do ... end`) also live as Handler
9
+ # subclasses: the translator emits `is_regex?` returning true plus
10
+ # `re_match?(path)` / `re_capture(path)` baking the literal regex
11
+ # into both methods. The literal is required because spinel can't
12
+ # build a Regexp from a string at runtime.
13
+ module Tep
14
+ class Handler
15
+ def handle(req, res)
16
+ ""
17
+ end
18
+
19
+ def is_regex?
20
+ false
21
+ end
22
+
23
+ def re_match?(path)
24
+ false
25
+ end
26
+
27
+ # Default returns an empty str_array. Subclasses for regex routes
28
+ # return up to 9 capture strings.
29
+ def re_capture(path)
30
+ empty = [""]
31
+ empty.delete_at(0)
32
+ empty
33
+ end
34
+ end
35
+ end