tina4ruby 3.12.10 → 3.12.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/cli.rb +3 -3
- data/lib/tina4/dev_admin.rb +152 -20
- data/lib/tina4/error_overlay.rb +32 -2
- data/lib/tina4/feedback.rb +307 -0
- data/lib/tina4/mcp.rb +237 -10
- data/lib/tina4/plan.rb +41 -13
- data/lib/tina4/public/__feedback/widget.js +96 -0
- data/lib/tina4/rack_app.rb +85 -4
- data/lib/tina4/request.rb +8 -4
- data/lib/tina4/router.rb +137 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65d79f819e3ac62c1e8d3447409b8c75530c239db2bb6a7df9066de4104495ed
|
|
4
|
+
data.tar.gz: d0b0af5dd0e914b880cc64a7b9aca29e6f46de65dcec3104b18e01ad174509b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e59eac2e37d78d1f7752cd26680338ff404794f6ff23dbe9ab2a1570ce3f458200fd5f488fefb8ef6f5f0b1764aa355def94dbed13a50149df30b4ea798059a
|
|
7
|
+
data.tar.gz: 7021e0596f5275b4daf0969f0690400e99222fe10f30547b31fc8ee747f266c88086fb577927953813ac53b52dad9d8ce95a7ecb7ae48bfd2c1aa50fa8ff5e47
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -265,7 +265,7 @@ module Tina4
|
|
|
265
265
|
|
|
266
266
|
db = Tina4.database
|
|
267
267
|
unless db
|
|
268
|
-
puts "No database configured. Set
|
|
268
|
+
puts "No database configured. Set TINA4_DATABASE_URL in your .env file."
|
|
269
269
|
return
|
|
270
270
|
end
|
|
271
271
|
|
|
@@ -298,7 +298,7 @@ module Tina4
|
|
|
298
298
|
|
|
299
299
|
db = Tina4.database
|
|
300
300
|
unless db
|
|
301
|
-
puts "No database configured. Set
|
|
301
|
+
puts "No database configured. Set TINA4_DATABASE_URL in your .env file."
|
|
302
302
|
return
|
|
303
303
|
end
|
|
304
304
|
|
|
@@ -341,7 +341,7 @@ module Tina4
|
|
|
341
341
|
|
|
342
342
|
db = Tina4.database
|
|
343
343
|
unless db
|
|
344
|
-
puts "No database configured. Set
|
|
344
|
+
puts "No database configured. Set TINA4_DATABASE_URL in your .env file."
|
|
345
345
|
return
|
|
346
346
|
end
|
|
347
347
|
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -369,6 +369,15 @@ module Tina4
|
|
|
369
369
|
path = env["PATH_INFO"] || "/"
|
|
370
370
|
method = env["REQUEST_METHOD"]
|
|
371
371
|
|
|
372
|
+
# Dynamic-path routes can't live in the case-tuple below — match
|
|
373
|
+
# them up front. /__dev/api/threads/{id}[/messages] is forwarded
|
|
374
|
+
# verbatim to the Rust agent's /threads/{id}[/messages] surface
|
|
375
|
+
# (mirrors Python's `_api_threads_sub`).
|
|
376
|
+
if path.start_with?("/__dev/api/threads/")
|
|
377
|
+
suffix = path[("/__dev/api".length)..] # leaves "/threads/{id}[/messages]"
|
|
378
|
+
return threads_sub_proxy(env, method, suffix)
|
|
379
|
+
end
|
|
380
|
+
|
|
372
381
|
case [method, path]
|
|
373
382
|
when ["GET", "/__dev"], ["GET", "/__dev/"]
|
|
374
383
|
serve_dashboard
|
|
@@ -382,6 +391,13 @@ module Tina4
|
|
|
382
391
|
@reload_file = body["file"] || ""
|
|
383
392
|
reload_type = body["type"] || "reload"
|
|
384
393
|
Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
|
|
394
|
+
# Re-discover so files dropped into src/routes/ register without
|
|
395
|
+
# a server restart. Idempotent — already-loaded files are skipped.
|
|
396
|
+
begin
|
|
397
|
+
Tina4::Router.rescan_routes!
|
|
398
|
+
rescue StandardError => e
|
|
399
|
+
Tina4::Log.error("Re-discover on reload failed: #{e.message}")
|
|
400
|
+
end
|
|
385
401
|
json_response({ ok: true, type: reload_type })
|
|
386
402
|
when ["GET", "/__dev/api/status"]
|
|
387
403
|
json_response(status_payload)
|
|
@@ -509,12 +525,21 @@ module Tina4
|
|
|
509
525
|
tool = (body && body["tool"]) || ""
|
|
510
526
|
json_response(run_tool(tool))
|
|
511
527
|
when ["POST", "/__dev/api/chat"]
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
528
|
+
# Proxy dev-admin chat to the Rust agent's /chat endpoint.
|
|
529
|
+
# The SPA POSTs {message, thread_id?, active_file?, settings?}
|
|
530
|
+
# and expects an SSE stream of `event: status/message/done`
|
|
531
|
+
# chunks. We forward the JSON body verbatim (active_file rides
|
|
532
|
+
# along) and pipe upstream bytes back as they arrive. Mirrors
|
|
533
|
+
# Python's `_api_chat` / `_proxy_to_supervisor` SSE path.
|
|
534
|
+
body = read_json_body(env) || {}
|
|
535
|
+
chat_proxy(body)
|
|
536
|
+
when ["GET", "/__dev/api/threads"]
|
|
537
|
+
# Parity with Python `_api_threads` (GET → list threads).
|
|
538
|
+
json_response(proxy_supervisor("/threads", method: "GET", query: env["QUERY_STRING"]))
|
|
539
|
+
when ["POST", "/__dev/api/threads"]
|
|
540
|
+
# Parity with Python `_api_threads` (POST → create thread).
|
|
541
|
+
body = read_json_body(env) || {}
|
|
542
|
+
json_response(proxy_supervisor("/threads", method: "POST", body: body))
|
|
518
543
|
when ["GET", "/__dev/api/connections"]
|
|
519
544
|
handle_connections_get
|
|
520
545
|
when ["POST", "/__dev/api/connections/test"]
|
|
@@ -893,10 +918,13 @@ module Tina4
|
|
|
893
918
|
key, val = line.split("=", 2)
|
|
894
919
|
key = key.strip
|
|
895
920
|
val = (val || "").strip.gsub(/\A["']|["']\z/, "")
|
|
921
|
+
# v3.12+ env vars are TINA4_-prefixed; the boot guard refuses to
|
|
922
|
+
# start with bare DATABASE_URL set, so the dashboard must read
|
|
923
|
+
# and write the prefixed names.
|
|
896
924
|
case key
|
|
897
|
-
when "
|
|
898
|
-
when "
|
|
899
|
-
when "
|
|
925
|
+
when "TINA4_DATABASE_URL" then url = val
|
|
926
|
+
when "TINA4_DATABASE_USERNAME" then username = val
|
|
927
|
+
when "TINA4_DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
|
|
900
928
|
end
|
|
901
929
|
end
|
|
902
930
|
end
|
|
@@ -954,7 +982,13 @@ module Tina4
|
|
|
954
982
|
begin
|
|
955
983
|
env_path = File.join(Dir.pwd, ".env")
|
|
956
984
|
lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
|
|
957
|
-
|
|
985
|
+
# v3.12+ env vars are TINA4_-prefixed; saving bare DATABASE_URL
|
|
986
|
+
# would trip the framework's legacy-env boot guard on next start.
|
|
987
|
+
keys_found = {
|
|
988
|
+
"TINA4_DATABASE_URL" => false,
|
|
989
|
+
"TINA4_DATABASE_USERNAME" => false,
|
|
990
|
+
"TINA4_DATABASE_PASSWORD" => false,
|
|
991
|
+
}
|
|
958
992
|
new_lines = []
|
|
959
993
|
lines.each do |line|
|
|
960
994
|
stripped = line.strip
|
|
@@ -964,20 +998,24 @@ module Tina4
|
|
|
964
998
|
end
|
|
965
999
|
key = stripped.split("=", 2).first.strip
|
|
966
1000
|
case key
|
|
967
|
-
when "
|
|
968
|
-
new_lines << "
|
|
969
|
-
keys_found["
|
|
970
|
-
when "
|
|
971
|
-
new_lines << "
|
|
972
|
-
keys_found["
|
|
973
|
-
when "
|
|
974
|
-
new_lines << "
|
|
975
|
-
keys_found["
|
|
1001
|
+
when "TINA4_DATABASE_URL"
|
|
1002
|
+
new_lines << "TINA4_DATABASE_URL=#{url}"
|
|
1003
|
+
keys_found["TINA4_DATABASE_URL"] = true
|
|
1004
|
+
when "TINA4_DATABASE_USERNAME"
|
|
1005
|
+
new_lines << "TINA4_DATABASE_USERNAME=#{username}"
|
|
1006
|
+
keys_found["TINA4_DATABASE_USERNAME"] = true
|
|
1007
|
+
when "TINA4_DATABASE_PASSWORD"
|
|
1008
|
+
new_lines << "TINA4_DATABASE_PASSWORD=#{password}"
|
|
1009
|
+
keys_found["TINA4_DATABASE_PASSWORD"] = true
|
|
976
1010
|
else
|
|
977
1011
|
new_lines << line
|
|
978
1012
|
end
|
|
979
1013
|
end
|
|
980
|
-
values = {
|
|
1014
|
+
values = {
|
|
1015
|
+
"TINA4_DATABASE_URL" => url,
|
|
1016
|
+
"TINA4_DATABASE_USERNAME" => username,
|
|
1017
|
+
"TINA4_DATABASE_PASSWORD" => password,
|
|
1018
|
+
}
|
|
981
1019
|
keys_found.each do |key, found|
|
|
982
1020
|
new_lines << "#{key}=#{values[key]}" unless found
|
|
983
1021
|
end
|
|
@@ -1075,6 +1113,21 @@ module Tina4
|
|
|
1075
1113
|
r["Content-Type"] = "application/json"
|
|
1076
1114
|
r.body = JSON.generate(body || {})
|
|
1077
1115
|
r
|
|
1116
|
+
when "PATCH"
|
|
1117
|
+
r = Net::HTTP::Patch.new(uri)
|
|
1118
|
+
r["Content-Type"] = "application/json"
|
|
1119
|
+
r.body = JSON.generate(body || {})
|
|
1120
|
+
r
|
|
1121
|
+
when "PUT"
|
|
1122
|
+
r = Net::HTTP::Put.new(uri)
|
|
1123
|
+
r["Content-Type"] = "application/json"
|
|
1124
|
+
r.body = JSON.generate(body || {})
|
|
1125
|
+
r
|
|
1126
|
+
when "DELETE"
|
|
1127
|
+
r = Net::HTTP::Delete.new(uri)
|
|
1128
|
+
r["Content-Type"] = "application/json"
|
|
1129
|
+
r.body = JSON.generate(body) if body
|
|
1130
|
+
r
|
|
1078
1131
|
else
|
|
1079
1132
|
Net::HTTP::Get.new(uri)
|
|
1080
1133
|
end
|
|
@@ -1089,6 +1142,85 @@ module Tina4
|
|
|
1089
1142
|
end
|
|
1090
1143
|
end
|
|
1091
1144
|
|
|
1145
|
+
# Proxy /__dev/api/threads/{id}[/messages] through to the Rust
|
|
1146
|
+
# agent. Mirrors Python's `_api_threads_sub`: we already stripped
|
|
1147
|
+
# the dev-admin prefix in handle_request, so `suffix` is the path
|
|
1148
|
+
# the agent expects (e.g. `/threads/abc` or `/threads/abc/messages`).
|
|
1149
|
+
# PATCH /threads/{id} (archive/rename) and GET /threads/{id}/messages
|
|
1150
|
+
# are the two shapes the SPA actually fires.
|
|
1151
|
+
def threads_sub_proxy(env, method, suffix)
|
|
1152
|
+
case method.upcase
|
|
1153
|
+
when "GET"
|
|
1154
|
+
json_response(proxy_supervisor(suffix, method: "GET", query: env["QUERY_STRING"]))
|
|
1155
|
+
when "POST"
|
|
1156
|
+
body = read_json_body(env) || {}
|
|
1157
|
+
json_response(proxy_supervisor(suffix, method: "POST", body: body))
|
|
1158
|
+
when "PATCH"
|
|
1159
|
+
body = read_json_body(env) || {}
|
|
1160
|
+
json_response(proxy_supervisor(suffix, method: "PATCH", body: body))
|
|
1161
|
+
when "PUT"
|
|
1162
|
+
body = read_json_body(env) || {}
|
|
1163
|
+
json_response(proxy_supervisor(suffix, method: "PUT", body: body))
|
|
1164
|
+
when "DELETE"
|
|
1165
|
+
body = read_json_body(env)
|
|
1166
|
+
json_response(proxy_supervisor(suffix, method: "DELETE", body: body))
|
|
1167
|
+
else
|
|
1168
|
+
[405, { "content-type" => "application/json; charset=utf-8" },
|
|
1169
|
+
[JSON.generate({ error: "method not allowed" })]]
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
# POST /chat — proxy the SPA's chat payload to the Rust agent and
|
|
1174
|
+
# pipe the upstream SSE response back to the browser. Active-file
|
|
1175
|
+
# content (when present in the body) rides along verbatim.
|
|
1176
|
+
#
|
|
1177
|
+
# NOTE on streaming: Tina4 Ruby's Rack app does not currently
|
|
1178
|
+
# expose a chunk-by-chunk streaming API to handlers (see
|
|
1179
|
+
# response.rb — `response.stream(&block)` is not yet wired through
|
|
1180
|
+
# the case-tuple dispatcher used here). We do the next best thing:
|
|
1181
|
+
# use Net::HTTP#request_get with a block so we receive upstream
|
|
1182
|
+
# SSE chunks as they arrive, buffer them, and return the assembled
|
|
1183
|
+
# body with the upstream content-type intact. The SPA's
|
|
1184
|
+
# EventSource reader works either way (it parses `data:` lines
|
|
1185
|
+
# regardless of arrival cadence) — the TODO below tracks
|
|
1186
|
+
# converting this to a true streamed Rack body once dev_admin
|
|
1187
|
+
# routes are migrated off the case-tuple dispatcher.
|
|
1188
|
+
def chat_proxy(body)
|
|
1189
|
+
base = supervisor_base
|
|
1190
|
+
begin
|
|
1191
|
+
uri = URI.parse("#{base}/chat")
|
|
1192
|
+
req = Net::HTTP::Post.new(uri)
|
|
1193
|
+
req["Content-Type"] = "application/json"
|
|
1194
|
+
req["Accept"] = "text/event-stream"
|
|
1195
|
+
req.body = JSON.generate(body || {})
|
|
1196
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
1197
|
+
http.open_timeout = 2
|
|
1198
|
+
# /chat runs the supervisor → planner → coder loop with one or
|
|
1199
|
+
# more LLM round-trips. Matches Python's 600s budget.
|
|
1200
|
+
http.read_timeout = 600
|
|
1201
|
+
chunks = []
|
|
1202
|
+
upstream_status = 200
|
|
1203
|
+
upstream_ct = "text/event-stream"
|
|
1204
|
+
http.request(req) do |resp|
|
|
1205
|
+
upstream_status = resp.code.to_i
|
|
1206
|
+
upstream_ct = resp["content-type"] || upstream_ct
|
|
1207
|
+
# TODO: stream chunks straight into the Rack response once
|
|
1208
|
+
# dev_admin migrates to response.stream(). For now we
|
|
1209
|
+
# buffer — the SPA's SSE reader still parses correctly.
|
|
1210
|
+
resp.read_body { |chunk| chunks << chunk }
|
|
1211
|
+
end
|
|
1212
|
+
[upstream_status, { "content-type" => upstream_ct }, [chunks.join]]
|
|
1213
|
+
rescue StandardError => e
|
|
1214
|
+
body_str = JSON.generate({
|
|
1215
|
+
error: "supervisor unavailable",
|
|
1216
|
+
detail: e.message,
|
|
1217
|
+
hint: "Run `tina4 serve` (starts the agent server) or set TINA4_SUPERVISOR_URL",
|
|
1218
|
+
supervisor: base
|
|
1219
|
+
})
|
|
1220
|
+
[503, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
|
|
1221
|
+
end
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1092
1224
|
def execute_proxy(body)
|
|
1093
1225
|
# Proxy POST /execute to the supervisor at framework_port + 2000.
|
|
1094
1226
|
# Pass through the response stream as-is (SSE or JSON).
|
data/lib/tina4/error_overlay.rb
CHANGED
|
@@ -42,12 +42,20 @@ module Tina4
|
|
|
42
42
|
exc_type = exception.class.name
|
|
43
43
|
exc_msg = exception.message
|
|
44
44
|
|
|
45
|
+
# Stamp captured_at once when the overlay is generated. Each frame
|
|
46
|
+
# compares its source file's mtime to this single timestamp and
|
|
47
|
+
# flags itself stale if the file changed afterwards — protects
|
|
48
|
+
# against the "browser cached an old overlay, then the AI rewrote
|
|
49
|
+
# the file" confusion where the displayed source no longer matches
|
|
50
|
+
# what actually raised the error.
|
|
51
|
+
captured_at = Time.now.to_f
|
|
52
|
+
|
|
45
53
|
# ── Stack trace ──
|
|
46
54
|
frames_html = +""
|
|
47
55
|
backtrace = exception.backtrace || []
|
|
48
56
|
backtrace.each do |line|
|
|
49
57
|
file, lineno, method = parse_backtrace_line(line)
|
|
50
|
-
frames_html << format_frame(file, lineno, method)
|
|
58
|
+
frames_html << format_frame(file, lineno, method, captured_at: captured_at)
|
|
51
59
|
end
|
|
52
60
|
|
|
53
61
|
# ── Request info ──
|
|
@@ -213,8 +221,9 @@ module Tina4
|
|
|
213
221
|
"#{rows}</div>"
|
|
214
222
|
end
|
|
215
223
|
|
|
216
|
-
def format_frame(filename, lineno, func_name)
|
|
224
|
+
def format_frame(filename, lineno, func_name, captured_at: 0.0)
|
|
217
225
|
source = (filename && lineno.positive?) ? format_source_block(filename, lineno) : ""
|
|
226
|
+
stale_badge = stale_file_badge(filename, captured_at)
|
|
218
227
|
"<div style=\"margin-bottom:16px;\">" \
|
|
219
228
|
"<div style=\"margin-bottom:4px;\">" \
|
|
220
229
|
"<span style=\"color:#{BLUE};\">#{esc(filename.to_s)}</span>" \
|
|
@@ -222,11 +231,32 @@ module Tina4
|
|
|
222
231
|
"<span style=\"color:#{YELLOW};\">#{lineno}</span>" \
|
|
223
232
|
"<span style=\"color:#{SUBTEXT};\"> in </span>" \
|
|
224
233
|
"<span style=\"color:#{GREEN};\">#{esc(func_name.to_s)}</span>" \
|
|
234
|
+
"#{stale_badge}" \
|
|
225
235
|
"</div>" \
|
|
226
236
|
"#{source}" \
|
|
227
237
|
"</div>"
|
|
228
238
|
end
|
|
229
239
|
|
|
240
|
+
# When the file on disk was modified AFTER the overlay was generated,
|
|
241
|
+
# emit a peach pill warning that the visible source may not match what
|
|
242
|
+
# actually failed. 0.5s margin to avoid filesystem-noise false positives.
|
|
243
|
+
def stale_file_badge(filename, captured_at)
|
|
244
|
+
return "" if captured_at.nil? || captured_at <= 0
|
|
245
|
+
return "" if filename.nil? || filename.to_s.empty?
|
|
246
|
+
return "" unless File.file?(filename)
|
|
247
|
+
|
|
248
|
+
mtime = File.mtime(filename).to_f
|
|
249
|
+
return "" if mtime <= captured_at + 0.5
|
|
250
|
+
|
|
251
|
+
mtime_str = Time.at(mtime).utc.strftime("%H:%M:%S")
|
|
252
|
+
"<span style=\"background:#{PEACH};color:#{BG};padding:1px 8px;" \
|
|
253
|
+
"border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;\">" \
|
|
254
|
+
"FILE MODIFIED @ #{mtime_str} UTC — source may not match what failed" \
|
|
255
|
+
"</span>"
|
|
256
|
+
rescue StandardError
|
|
257
|
+
""
|
|
258
|
+
end
|
|
259
|
+
|
|
230
260
|
def collapsible(title, content, open_by_default: false)
|
|
231
261
|
open_attr = open_by_default ? " open" : ""
|
|
232
262
|
"<details style=\"margin-top:16px;\"#{open_attr}>" \
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Tina4
|
|
8
|
+
# ── Customer feedback widget — server-side plumbing ─────────────────
|
|
9
|
+
#
|
|
10
|
+
# Mirrors tina4_python/dev_admin/__init__.py lines 1436-1645. The
|
|
11
|
+
# widget is for END USERS of a shipped Tina4 app (not developers).
|
|
12
|
+
# Whitelisted users get a floating bubble that proxies one
|
|
13
|
+
# conversational turn at a time to the Rust agent's intake endpoint
|
|
14
|
+
# at <supervisor>/feedback/intake.
|
|
15
|
+
#
|
|
16
|
+
# Flow:
|
|
17
|
+
# 1. Framework middleware injects <script src="/__feedback/widget.js">
|
|
18
|
+
# into HTML responses for whitelisted users.
|
|
19
|
+
# 2. Widget POSTs to /__feedback/api/turn for each conversational turn.
|
|
20
|
+
# 3. The Ruby handler verifies whitelist + rate-limit, stamps the
|
|
21
|
+
# user identity server-side (client cannot fake `sender`), then
|
|
22
|
+
# forwards to the Rust agent's /feedback/intake.
|
|
23
|
+
# 4. Finalised tickets land in .tina4/chat/threads.json with
|
|
24
|
+
# kind:"feedback". Developer sees them in the dev admin sidebar.
|
|
25
|
+
module Feedback
|
|
26
|
+
RATE_WINDOW = 3600 # 1 hour
|
|
27
|
+
RATE_MAX = 5 # submissions/turns per user per hour
|
|
28
|
+
|
|
29
|
+
# Class-level mutex-guarded hash {user => [timestamps]}. Mirrors the
|
|
30
|
+
# Python `_FEEDBACK_RATE_LIMIT` dict — process-local, no persistence.
|
|
31
|
+
@rate_limit = {}
|
|
32
|
+
@rate_mutex = Mutex.new
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
attr_reader :rate_mutex
|
|
36
|
+
|
|
37
|
+
# Test/reset helper — clears the in-memory rate-limit table.
|
|
38
|
+
def reset_rate_limit!
|
|
39
|
+
@rate_mutex.synchronize { @rate_limit = {} }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Hard master switch.
|
|
43
|
+
#
|
|
44
|
+
# Both gates required for the widget to render or the API to
|
|
45
|
+
# accept submissions:
|
|
46
|
+
# - TINA4_ENABLE_FEEDBACK=true (explicit opt-in — off by default)
|
|
47
|
+
# - TINA4_FEEDBACK_WHITELIST=... (non-empty list of users)
|
|
48
|
+
#
|
|
49
|
+
# Splitting the toggle from the whitelist lets the developer leave
|
|
50
|
+
# the whitelist intact while pausing the feature in production for
|
|
51
|
+
# a release (set TINA4_ENABLE_FEEDBACK=false → widget vanishes
|
|
52
|
+
# everywhere; whitelist comes back online with one env flip).
|
|
53
|
+
def feedback_enabled?
|
|
54
|
+
raw = ENV["TINA4_ENABLE_FEEDBACK"].to_s.strip.downcase
|
|
55
|
+
%w[1 true yes on].include?(raw)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Comma-separated emails / user IDs in env. Empty = no one allowed.
|
|
59
|
+
def feedback_whitelist
|
|
60
|
+
return [] unless feedback_enabled?
|
|
61
|
+
raw = ENV["TINA4_FEEDBACK_WHITELIST"].to_s.strip
|
|
62
|
+
return [] if raw.empty?
|
|
63
|
+
raw.split(",").map { |e| e.strip.downcase }.reject(&:empty?)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Best-effort user identity from auth headers.
|
|
67
|
+
#
|
|
68
|
+
# Priority:
|
|
69
|
+
# 1. JWT/Bearer token via Tina4::Auth.authenticate_request —
|
|
70
|
+
# pulls email/sub/user_id claim.
|
|
71
|
+
# 2. TINA4_FEEDBACK_DEV_USER env var override (LOCAL DEV ONLY —
|
|
72
|
+
# lets the framework owner test the widget without a full
|
|
73
|
+
# auth setup in the test project).
|
|
74
|
+
def feedback_identify_user(request)
|
|
75
|
+
begin
|
|
76
|
+
headers = request_headers(request)
|
|
77
|
+
payload = Tina4::Auth.authenticate_request(headers)
|
|
78
|
+
if payload.is_a?(Hash)
|
|
79
|
+
%w[email sub user_id].each do |key|
|
|
80
|
+
v = payload[key] || payload[key.to_sym]
|
|
81
|
+
return v.to_s.strip.downcase if v && !v.to_s.strip.empty?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError
|
|
85
|
+
# fall through to dev-user override
|
|
86
|
+
end
|
|
87
|
+
dev_user = ENV["TINA4_FEEDBACK_DEV_USER"].to_s.strip
|
|
88
|
+
return dev_user.downcase unless dev_user.empty?
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns [allowed, identity]. Both halves must be truthy to act.
|
|
93
|
+
def feedback_is_whitelisted(request)
|
|
94
|
+
wl = feedback_whitelist
|
|
95
|
+
return [false, nil] if wl.empty?
|
|
96
|
+
user = feedback_identify_user(request)
|
|
97
|
+
return [false, nil] unless user
|
|
98
|
+
[wl.include?(user), user]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 5 turns/hour per user. Prunes old timestamps lazily.
|
|
102
|
+
def feedback_rate_limit_ok(user)
|
|
103
|
+
now = Time.now.to_f
|
|
104
|
+
@rate_mutex.synchronize do
|
|
105
|
+
hits = (@rate_limit[user] || []).select { |t| now - t < RATE_WINDOW }
|
|
106
|
+
if hits.size >= RATE_MAX
|
|
107
|
+
@rate_limit[user] = hits
|
|
108
|
+
return false
|
|
109
|
+
end
|
|
110
|
+
hits << now
|
|
111
|
+
@rate_limit[user] = hits
|
|
112
|
+
true
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Insert the widget <script> into HTML responses for whitelisted users.
|
|
117
|
+
#
|
|
118
|
+
# Called from the Rack middleware right before the body is sent.
|
|
119
|
+
# No-op if:
|
|
120
|
+
# - The request is for a /__dev or /__feedback path (developer
|
|
121
|
+
# dashboard / widget assets — never inject the customer widget
|
|
122
|
+
# on developer pages; the dev admin has its OWN chat trigger).
|
|
123
|
+
# - TINA4_ENABLE_FEEDBACK + TINA4_FEEDBACK_WHITELIST not both set
|
|
124
|
+
# - Requesting user isn't in the whitelist
|
|
125
|
+
# - Response doesn't have a closing </body> tag (fragment, JSON, etc.)
|
|
126
|
+
# Idempotent: a second call won't double-inject (looks for marker).
|
|
127
|
+
def inject_feedback_widget(request, html)
|
|
128
|
+
return html if html.nil? || html.empty?
|
|
129
|
+
# The customer feedback widget is for END USERS of the shipped
|
|
130
|
+
# app — injecting on developer-only paths creates a confusing
|
|
131
|
+
# "two bubbles" UX where the dev chat trigger + customer
|
|
132
|
+
# feedback bubble sit on top of each other. Hard exclusion at
|
|
133
|
+
# the framework layer.
|
|
134
|
+
path = request_path(request)
|
|
135
|
+
return html if path.start_with?("/__dev") || path.start_with?("/__feedback")
|
|
136
|
+
allowed, _user = feedback_is_whitelisted(request)
|
|
137
|
+
return html unless allowed
|
|
138
|
+
return html if html.include?("data-tina4-feedback")
|
|
139
|
+
snippet = '<script src="/__feedback/widget.js" data-tina4-feedback></script>'
|
|
140
|
+
idx = html.rindex("</body>")
|
|
141
|
+
return html unless idx
|
|
142
|
+
html[0...idx] + snippet + html[idx..]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# POST /__feedback/api/turn — whitelist check + rate-limit + stamp
|
|
146
|
+
# `sender` server-side, forward to Rust agent
|
|
147
|
+
# <supervisor>/feedback/intake. Returns a Rack response triple.
|
|
148
|
+
def handle_turn(env)
|
|
149
|
+
request = build_request_wrapper(env)
|
|
150
|
+
allowed, user = feedback_is_whitelisted(request)
|
|
151
|
+
return json_response({ error: "not authorised for feedback" }, 403) unless allowed
|
|
152
|
+
unless feedback_rate_limit_ok(user)
|
|
153
|
+
return json_response({
|
|
154
|
+
error: "rate limit exceeded",
|
|
155
|
+
hint: "max #{RATE_MAX} turns per hour"
|
|
156
|
+
}, 429)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
body = read_json_body(env)
|
|
160
|
+
return json_response({ error: "expected JSON body" }, 400) unless body.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
forward_body = body.dup
|
|
163
|
+
forward_body["sender"] = user # server-stamped identity
|
|
164
|
+
|
|
165
|
+
base = supervisor_base
|
|
166
|
+
uri = URI.parse("#{base}/feedback/intake")
|
|
167
|
+
begin
|
|
168
|
+
req = Net::HTTP::Post.new(uri)
|
|
169
|
+
req["Content-Type"] = "application/json"
|
|
170
|
+
req.body = JSON.generate(forward_body)
|
|
171
|
+
resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 60) { |h| h.request(req) }
|
|
172
|
+
parsed = begin
|
|
173
|
+
JSON.parse(resp.body.to_s)
|
|
174
|
+
rescue JSON::ParserError
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
status_code = resp.code.to_i
|
|
178
|
+
status_code = 200 if status_code.zero?
|
|
179
|
+
if parsed
|
|
180
|
+
json_response(parsed, status_code)
|
|
181
|
+
else
|
|
182
|
+
[status_code, { "content-type" => "text/plain; charset=utf-8" }, [resp.body.to_s]]
|
|
183
|
+
end
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
json_response({
|
|
186
|
+
error: "agent unreachable",
|
|
187
|
+
detail: e.message
|
|
188
|
+
}, 502)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# GET /__feedback/widget.js — serve the bundle at
|
|
193
|
+
# lib/tina4/public/__feedback/widget.js. Cache-Control: no-cache,
|
|
194
|
+
# must-revalidate so browsers re-check the bundle on every load.
|
|
195
|
+
# Without this an old cached bundle (e.g. one that pre-dates the
|
|
196
|
+
# path-block guard against rendering on /__dev/) can persist for
|
|
197
|
+
# days and keep painting the bubble on the dev admin even after
|
|
198
|
+
# the server-side script-tag injection is fixed.
|
|
199
|
+
def handle_widget_js(_env)
|
|
200
|
+
js_path = File.expand_path("public/__feedback/widget.js", __dir__)
|
|
201
|
+
body = if File.file?(js_path)
|
|
202
|
+
File.binread(js_path)
|
|
203
|
+
else
|
|
204
|
+
"console.warn('tina4-feedback-widget bundle not built yet');"
|
|
205
|
+
end
|
|
206
|
+
headers = {
|
|
207
|
+
"content-type" => "application/javascript",
|
|
208
|
+
"cache-control" => "no-cache, must-revalidate",
|
|
209
|
+
"pragma" => "no-cache"
|
|
210
|
+
}
|
|
211
|
+
[200, headers, [body]]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Dispatcher used by RackApp — returns a Rack triple if the path
|
|
215
|
+
# matches a /__feedback route, else nil.
|
|
216
|
+
def handle_request(env)
|
|
217
|
+
path = env["PATH_INFO"] || "/"
|
|
218
|
+
method = env["REQUEST_METHOD"]
|
|
219
|
+
case [method, path]
|
|
220
|
+
when ["GET", "/__feedback/widget.js"]
|
|
221
|
+
handle_widget_js(env)
|
|
222
|
+
when ["POST", "/__feedback/api/turn"]
|
|
223
|
+
handle_turn(env)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
# Locate the supervisor (Rust agent) base URL. Mirrors the
|
|
230
|
+
# Tier 3 helper in dev_admin.rb so both modules agree on the
|
|
231
|
+
# endpoint resolution rule.
|
|
232
|
+
def supervisor_base
|
|
233
|
+
if defined?(Tina4::DevAdmin) && Tina4::DevAdmin.respond_to?(:supervisor_base, true)
|
|
234
|
+
return Tina4::DevAdmin.send(:supervisor_base)
|
|
235
|
+
end
|
|
236
|
+
base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
|
|
237
|
+
return base unless base.empty?
|
|
238
|
+
port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
|
|
239
|
+
"http://127.0.0.1:#{port}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def request_path(request)
|
|
243
|
+
if request.is_a?(Hash)
|
|
244
|
+
(request["PATH_INFO"] || request[:path] || "").to_s
|
|
245
|
+
elsif request.respond_to?(:path)
|
|
246
|
+
request.path.to_s
|
|
247
|
+
else
|
|
248
|
+
""
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Extract Rack-style headers ({"HTTP_AUTHORIZATION" => "..."}) so
|
|
253
|
+
# Tina4::Auth.authenticate_request can consume them. Accepts a
|
|
254
|
+
# Rack env hash, a Tina4::Request, or a plain hash of headers.
|
|
255
|
+
def request_headers(request)
|
|
256
|
+
if request.is_a?(Hash) && request.keys.any? { |k| k.to_s.start_with?("HTTP_") }
|
|
257
|
+
return request
|
|
258
|
+
end
|
|
259
|
+
if request.respond_to?(:env)
|
|
260
|
+
return request.env
|
|
261
|
+
end
|
|
262
|
+
if request.respond_to?(:headers)
|
|
263
|
+
h = request.headers || {}
|
|
264
|
+
# Auth.authenticate_request looks for HTTP_AUTHORIZATION or Authorization
|
|
265
|
+
rack_like = {}
|
|
266
|
+
h.each do |k, v|
|
|
267
|
+
key = k.to_s
|
|
268
|
+
if key.downcase == "authorization"
|
|
269
|
+
rack_like["HTTP_AUTHORIZATION"] = v
|
|
270
|
+
else
|
|
271
|
+
rack_like[key] = v
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
return rack_like
|
|
275
|
+
end
|
|
276
|
+
{}
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Lightweight request wrapper used by handle_turn so the same
|
|
280
|
+
# whitelist/identity helpers work whether the caller passes a Rack
|
|
281
|
+
# env or a Tina4::Request.
|
|
282
|
+
def build_request_wrapper(env)
|
|
283
|
+
Struct.new(:path, :env, :headers).new(
|
|
284
|
+
env["PATH_INFO"] || "/",
|
|
285
|
+
env,
|
|
286
|
+
env # Rack env IS the headers source for Tina4::Auth
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def read_json_body(env)
|
|
291
|
+
input = env["rack.input"]
|
|
292
|
+
return nil unless input
|
|
293
|
+
input.rewind if input.respond_to?(:rewind)
|
|
294
|
+
raw = input.read
|
|
295
|
+
return nil if raw.nil? || raw.empty?
|
|
296
|
+
JSON.parse(raw)
|
|
297
|
+
rescue JSON::ParserError
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def json_response(data, status = 200)
|
|
302
|
+
[status, { "content-type" => "application/json; charset=utf-8" },
|
|
303
|
+
[JSON.generate(data)]]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|