tina4ruby 3.12.9 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f93732baf6d41660ae5294101c0fc9c619ab642d0b7be03306d462e095ce827
4
- data.tar.gz: 93bc70862b49e7d01fe3a8c25233696e25eaf882281fa2964ce60191e0128d48
3
+ metadata.gz: 65d79f819e3ac62c1e8d3447409b8c75530c239db2bb6a7df9066de4104495ed
4
+ data.tar.gz: d0b0af5dd0e914b880cc64a7b9aca29e6f46de65dcec3104b18e01ad174509b8
5
5
  SHA512:
6
- metadata.gz: 5a31af9bbe283e68dc35011e043dc2523d51746bef925125dcd18194c4e5fe97ee131333fc3f786d72ed22ed3640bde67abb9d053505f3a6669315044b00ba52
7
- data.tar.gz: 6ea8a2e4fd0392292b33b0842f91e25d4b92a5c35028f07e9e42839729ec679e1a419827312e7287fdd3f1fd281103f147e9bda39129d39166c41699a09b49a7
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 DATABASE_URL in your .env file."
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 DATABASE_URL in your .env file."
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 DATABASE_URL in your .env file."
344
+ puts "No database configured. Set TINA4_DATABASE_URL in your .env file."
345
345
  return
346
346
  end
347
347
 
@@ -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
- body = read_json_body(env)
513
- message = (body && body["message"]) || ""
514
- json_response({
515
- reply: "Chat is not yet connected to an AI backend. You said: \"#{message}\"",
516
- timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
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 "DATABASE_URL" then url = val
898
- when "DATABASE_USERNAME" then username = val
899
- when "DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
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
- keys_found = { "DATABASE_URL" => false, "DATABASE_USERNAME" => false, "DATABASE_PASSWORD" => false }
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 "DATABASE_URL"
968
- new_lines << "DATABASE_URL=#{url}"
969
- keys_found["DATABASE_URL"] = true
970
- when "DATABASE_USERNAME"
971
- new_lines << "DATABASE_USERNAME=#{username}"
972
- keys_found["DATABASE_USERNAME"] = true
973
- when "DATABASE_PASSWORD"
974
- new_lines << "DATABASE_PASSWORD=#{password}"
975
- keys_found["DATABASE_PASSWORD"] = true
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 = { "DATABASE_URL" => url, "DATABASE_USERNAME" => username, "DATABASE_PASSWORD" => password }
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).
@@ -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 &mdash; 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
data/lib/tina4/mcp.rb CHANGED
@@ -21,6 +21,7 @@
21
21
  require "json"
22
22
  require "socket"
23
23
  require "fileutils"
24
+ require "open3"
24
25
 
25
26
  module Tina4
26
27
  # ── JSON-RPC 2.0 codec ────────────────────────────────────────────
@@ -457,7 +458,111 @@ module Tina4
457
458
  project_root = File.expand_path(Dir.pwd)
458
459
 
459
460
  # ── Helpers ────────────────────────────────────────
461
+ # Append a structured line to `.tina4/agent.log` AND echo to STDERR.
462
+ # Mirrors the Python `_agent_log` helper so a single log file
463
+ # captures every agent action regardless of which side of the
464
+ # stack performed it. Cheap — never blocks the caller on I/O
465
+ # failure.
466
+ agent_log = lambda do |category, message|
467
+ begin
468
+ log_dir = File.join(project_root, ".tina4")
469
+ FileUtils.mkdir_p(log_dir)
470
+ log_path = File.join(log_dir, "agent.log")
471
+ ts = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
472
+ File.open(log_path, "a") { |f| f.write("#{ts} [#{category}] #{message}\n") }
473
+ rescue
474
+ # logging must never fail the actual call
475
+ end
476
+ warn " [agent #{category}] #{message}"
477
+ end
478
+
479
+ # Paths that look like prose rather than filesystem paths. AI
480
+ # agents occasionally pass natural language ("The plan requires
481
+ # implementing...") as `path` to file_write and produce folders
482
+ # with prose for names. Returns an error string on rejection,
483
+ # nil on acceptance.
484
+ sane_path_segment = /\A[A-Za-z0-9._\-]+\z/
485
+ looks_like_prose = lambda do |rel_path|
486
+ if rel_path.nil? || rel_path.strip.empty?
487
+ break "path is empty"
488
+ end
489
+ if rel_path.length > 300
490
+ break "path too long (#{rel_path.length} chars); use a real filename"
491
+ end
492
+ bad_sequences = ["`", "\n", "\t", " ", " — ", " (", " [", "?", "*", "<", ">", "|"]
493
+ bad_hit = bad_sequences.find { |bad| rel_path.include?(bad) }
494
+ if bad_hit
495
+ break "path contains illegal character sequence #{bad_hit.inspect} — looks like prose, not a filename"
496
+ end
497
+ segment_error = nil
498
+ rel_path.split("/").each do |seg|
499
+ next if seg.empty? || seg == "." || seg == ".."
500
+ if seg.length > 80
501
+ segment_error = "path segment too long: #{seg[0, 60].inspect}… — use a short filename"
502
+ break
503
+ end
504
+ unless sane_path_segment.match?(seg)
505
+ segment_error = "path segment #{seg.inspect} contains disallowed characters — stick to [A-Za-z0-9._-]"
506
+ break
507
+ end
508
+ end
509
+ segment_error
510
+ end
511
+
512
+ # Rewrite bare top-level Tina4-conventional directories into
513
+ # their `src/<dir>/` canonical form. The framework's
514
+ # auto-discovery only scans `src/`, so a file at
515
+ # `templates/foo.twig` is dead weight — the framework never
516
+ # loads it. Mirrors normalize_coder_path() in the Python agent.
517
+ normalize_coder_path = lambda do |rel_path|
518
+ passthrough_prefixes = ["src/", "migrations/", "plan/", "tests/",
519
+ "test/", ".tina4/"]
520
+ passthrough_files = %w[app.py app.ts app.rb index.php composer.json
521
+ package.json Gemfile pyproject.toml
522
+ requirements.txt .env .env.example]
523
+ if passthrough_prefixes.any? { |p| rel_path.start_with?(p) }
524
+ next rel_path
525
+ end
526
+ if passthrough_files.include?(rel_path)
527
+ next rel_path
528
+ end
529
+ %w[routes orm templates seeds controllers models middleware].each do |d|
530
+ if rel_path.start_with?("#{d}/")
531
+ rewritten = "src/#{rel_path}"
532
+ agent_log.call("write.path_normalized", "#{rel_path} → #{rewritten}")
533
+ return rewritten
534
+ end
535
+ end
536
+ rel_path
537
+ end
538
+
539
+ # Copy `target` into `.tina4/backups/` with a timestamped name.
540
+ # Returns the relative backup path on success, nil on failure.
541
+ agent_backup = lambda do |target|
542
+ begin
543
+ next nil unless File.file?(target)
544
+ backup_dir = File.join(project_root, ".tina4", "backups")
545
+ FileUtils.mkdir_p(backup_dir)
546
+ rel = if target.start_with?("#{project_root}/")
547
+ target.sub("#{project_root}/", "")
548
+ else
549
+ File.basename(target)
550
+ end
551
+ safe = rel.gsub("/", "__").gsub("\\", "__")
552
+ ts = Time.now.utc.strftime("%Y-%m-%dT%H-%M-%SZ")
553
+ backup_name = "#{safe}.#{ts}.bak"
554
+ backup_path = File.join(backup_dir, backup_name)
555
+ File.binwrite(backup_path, File.binread(target))
556
+ ".tina4/backups/#{backup_name}"
557
+ rescue => e
558
+ agent_log.call("write.backup_failed", "#{target}: #{e.message}")
559
+ nil
560
+ end
561
+ end
562
+
460
563
  safe_path = lambda do |rel_path|
564
+ err = looks_like_prose.call(rel_path)
565
+ raise ArgumentError, "Invalid path #{rel_path.inspect}: #{err}" if err
461
566
  resolved = File.expand_path(rel_path, project_root)
462
567
  unless resolved.start_with?(project_root)
463
568
  raise ArgumentError, "Path escapes project directory: #{rel_path}"
@@ -465,6 +570,52 @@ module Tina4
465
570
  resolved
466
571
  end
467
572
 
573
+ # Try `ruby -c` on a freshly-written Ruby file to catch syntax
574
+ # errors BEFORE the next request hits the broken handler.
575
+ # Mirrors _verify_python_import() in tina4_python/mcp/tools.py.
576
+ #
577
+ # Returns nil on success (or when verification is skipped), or
578
+ # the captured error string on failure. Only checks files under
579
+ # src/ that end in .rb — skips spec_helper.rb, *_spec.rb, and
580
+ # test_*.rb because those have their own loading patterns
581
+ # (mirrors Python's skip of __init__.py / conftest.py / test_*.py).
582
+ #
583
+ # Why this matters: the AI coder repeatedly produces Ruby with
584
+ # unclosed blocks, missing `end`, dangling parens. Running
585
+ # `ruby -c` right after write catches it inline and surfaces
586
+ # the real Ruby error in the file_write response — the LLM
587
+ # sees the error on its next turn and can retry with context.
588
+ verify_ruby_syntax = lambda do |rel_path|
589
+ next nil unless rel_path.end_with?(".rb")
590
+ next nil unless rel_path.start_with?("src/")
591
+ base = File.basename(rel_path)
592
+ next nil if base == "spec_helper.rb"
593
+ next nil if base.end_with?("_spec.rb")
594
+ next nil if base.start_with?("test_")
595
+
596
+ abs_path = File.expand_path(rel_path, project_root)
597
+ begin
598
+ stdout, stderr, status = Open3.capture3("ruby", "-c", abs_path)
599
+ rescue StandardError => e
600
+ next "verification subprocess failed: #{e.message}"
601
+ end
602
+
603
+ next nil if status.success?
604
+
605
+ # Pull the first meaningful stderr line — ruby -c emits
606
+ # "<file>:<line>: syntax error, ...". Strip the absolute
607
+ # path prefix for readability.
608
+ err_lines = (stderr || "").strip.split("\n")
609
+ if err_lines.empty?
610
+ next "syntax check failed (exit #{status.exitstatus}, no stderr)"
611
+ end
612
+ line = err_lines.first.strip
613
+ # Strip the absolute project_root prefix so the error reads
614
+ # as "src/routes/foo.rb:3: syntax error, ..." instead of the
615
+ # full /Users/... path.
616
+ line.sub("#{project_root}/", "")
617
+ end
618
+
468
619
  redact_env = lambda do |key, value|
469
620
  sensitive = %w[secret password token key credential api_key]
470
621
  if sensitive.any? { |s| key.downcase.include?(s) }
@@ -549,12 +700,55 @@ module Tina4
549
700
  }, "Read a project file")
550
701
 
551
702
  server.register_tool("file_write", lambda { |path:, content:|
703
+ # 1. Coder-path normalization — rewrite bare top-level Tina4
704
+ # directories (templates/, routes/, orm/, ...) into their
705
+ # src/ canonical form before resolving.
706
+ path = normalize_coder_path.call(path)
707
+ # 2. safe_path runs looks_like_prose then sandbox check.
552
708
  p = safe_path.call(path)
709
+
710
+ old_bytes = File.file?(p) ? File.binread(p) : ""
711
+ old_size = old_bytes.bytesize
712
+ old_lines = old_bytes.count("\n")
713
+ new_bytes = content.to_s
714
+ new_size = new_bytes.bytesize
715
+ new_lines = new_bytes.count("\n")
716
+ rel = p.start_with?("#{project_root}/") ? p.sub("#{project_root}/", "") : path
717
+
718
+ # 3. Truncation guard — refuse suspicious shrinkage on
719
+ # non-trivial files (>200B → <30% of size).
720
+ if old_size > 200 && (new_size * 100) < (old_size * 30)
721
+ msg = "REFUSED #{rel} (would shrink #{old_size} → #{new_size} bytes / " \
722
+ "#{old_lines} → #{new_lines} lines, looks truncated)"
723
+ agent_log.call("write.refused", msg)
724
+ next { "error" => msg, "refused" => true, "old_bytes" => old_size, "new_bytes" => new_size }
725
+ end
726
+
727
+ # 4. Backup before overwrite.
728
+ backup_rel = old_size > 0 ? agent_backup.call(p) : nil
729
+
553
730
  FileUtils.mkdir_p(File.dirname(p))
554
- File.write(p, content, encoding: "utf-8")
555
- rel = p.sub("#{project_root}/", "")
556
- { "written" => rel, "bytes" => content.bytesize }
557
- }, "Write or update a project file")
731
+ File.write(p, new_bytes, encoding: "utf-8")
732
+
733
+ # 5. Audit log.
734
+ agent_log.call("write.ok",
735
+ "#{rel} (#{old_size}B/#{old_lines}L → #{new_size}B/#{new_lines}L, " \
736
+ "backup: #{backup_rel || '(no prior file)'})")
737
+
738
+ result = { "written" => rel, "bytes" => new_size }
739
+ result["backup"] = backup_rel if backup_rel
740
+
741
+ # 6. Post-write syntax check — catch hallucinated Ruby
742
+ # (missing `end`, unclosed parens, etc.) before the next
743
+ # request hits the broken handler.
744
+ err = verify_ruby_syntax.call(rel)
745
+ if err
746
+ result["import_error"] = err
747
+ agent_log.call("write.import_failed", "#{rel}: #{err}")
748
+ end
749
+
750
+ result
751
+ }, "Write or update a project file (with backup, truncation guard, audit log)")
558
752
 
559
753
  server.register_tool("file_list", lambda { |path: "."|
560
754
  p = safe_path.call(path)
@@ -706,25 +900,58 @@ module Tina4
706
900
 
707
901
  # ── File patch ────────────────────────────────────
708
902
  server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
903
+ # 1. Normalize, 2. safe_path (which runs the prose check).
904
+ path = normalize_coder_path.call(path)
709
905
  p = safe_path.call(path)
710
- return { "error" => "File not found: #{path}" } unless File.file?(p)
906
+
907
+ # 3. Existence check.
908
+ next { "error" => "File not found: #{path}" } unless File.file?(p)
909
+
711
910
  original = File.read(p, encoding: "utf-8")
712
911
  occurrences = original.scan(old_string).size
713
- return { "error" => "old_string not found in #{path}" } if occurrences.zero?
912
+
913
+ # 4. Match-count guard (already-existing behaviour).
914
+ next { "error" => "old_string not found in #{path}" } if occurrences.zero?
714
915
  if occurrences != count.to_i
715
- return { "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." }
916
+ next({ "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." })
716
917
  end
918
+
717
919
  updated = original.sub(old_string, new_string)
718
920
  # Ruby String#sub replaces first; if count > 1, do N replacements
719
921
  if count.to_i > 1
720
922
  updated = original.dup
721
923
  count.to_i.times { updated.sub!(old_string, new_string) }
722
924
  end
925
+
926
+ rel = p.start_with?("#{project_root}/") ? p.sub("#{project_root}/", "") : path
927
+
928
+ # 5. Backup before overwrite — same path layout as file_write
929
+ # so recovery is uniform regardless of which tool touched
930
+ # the file.
931
+ backup_rel = agent_backup.call(p)
932
+
723
933
  File.write(p, updated, encoding: "utf-8")
724
- rel = p.sub("#{project_root}/", "")
934
+
725
935
  Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
726
- { "patched" => rel, "replacements" => count.to_i, "bytes" => updated.bytesize }
727
- }, "Targeted edit: replace old_string with new_string in a file")
936
+
937
+ old_size = original.bytesize
938
+ new_size = updated.bytesize
939
+ agent_log.call("patch.ok",
940
+ "#{rel} (replaced #{count.to_i}× old_string, " \
941
+ "#{old_size}B → #{new_size}B, backup: #{backup_rel || '(none)'})")
942
+
943
+ result = { "patched" => rel, "replacements" => count.to_i, "bytes" => new_size }
944
+ result["backup"] = backup_rel if backup_rel
945
+
946
+ # Post-patch syntax check — same rationale as file_write.
947
+ err = verify_ruby_syntax.call(rel)
948
+ if err
949
+ result["import_error"] = err
950
+ agent_log.call("patch.import_failed", "#{rel}: #{err}")
951
+ end
952
+
953
+ result
954
+ }, "Targeted edit: replace old_string with new_string in a file (with backup + audit log)")
728
955
 
729
956
  # ── Docs tools ────────────────────────────────────
730
957
  framework_doc_paths = lambda do
data/lib/tina4/plan.rb CHANGED
@@ -125,23 +125,51 @@ module Tina4
125
125
 
126
126
  # ── Public API ─────────────────────────────────────────────
127
127
 
128
+ # All plan files — merged from `plan/` (user-curated) and
129
+ # `.tina4/plans/` (where the Rust supervisor's planner writes).
130
+ #
131
+ # Two directories exist because of a historic split: the framework
132
+ # treats `plan/` as the canonical project location, but the Rust
133
+ # agent's planner writes to `.tina4/plans/` (alongside other
134
+ # AI-state artefacts like chat history). Until those are unified we
135
+ # read both so plans created either way are discoverable. Dedup by
136
+ # filename — plan/ wins on collision. Sorted newest-first by
137
+ # filename (filenames start with unix timestamps).
128
138
  def list_plans
129
- d = plan_dir
130
139
  cur = current_name || ""
140
+ # Order matters for dedup: user-curated plan/ first, then .tina4/plans/.
141
+ dirs = []
142
+ primary = File.join(project_root, PLAN_DIR)
143
+ dirs << primary if Dir.exist?(primary)
144
+ rust_plans = File.join(project_root, ".tina4", "plans")
145
+ dirs << rust_plans if Dir.exist?(rust_plans)
146
+
147
+ seen = {}
131
148
  out = []
132
- Dir.glob(File.join(d, "*.md")).sort.each do |path|
133
- name = File.basename(path)
134
- parsed = parse(File.read(path, encoding: "utf-8"))
135
- total = parsed["steps"].size
136
- done = parsed["steps"].count { |s| s["done"] }
137
- out << {
138
- "name" => name,
139
- "title" => parsed["title"].to_s.empty? ? File.basename(name, ".md") : parsed["title"],
140
- "steps_total" => total,
141
- "steps_done" => done,
142
- "is_current" => name == cur
143
- }
149
+ dirs.each do |dir|
150
+ Dir.glob(File.join(dir, "*.md")).sort.each do |path|
151
+ name = File.basename(path)
152
+ next if seen.key?(name) # plan/ wins over .tina4/plans/ on name clash
153
+ seen[name] = true
154
+ parsed = parse(File.read(path, encoding: "utf-8"))
155
+ total = parsed["steps"].size
156
+ done = parsed["steps"].count { |s| s["done"] }
157
+ out << {
158
+ "name" => name,
159
+ "title" => parsed["title"].to_s.empty? ? File.basename(name, ".md") : parsed["title"],
160
+ "steps_total" => total,
161
+ "steps_done" => done,
162
+ "is_current" => name == cur,
163
+ # Relative path from project root — lets the SPA open the
164
+ # right file in the editor regardless of which dir the
165
+ # plan came from.
166
+ "path" => path.sub("#{project_root}/", "")
167
+ }
168
+ end
144
169
  end
170
+ # Newest first by name (filenames start with unix timestamps).
171
+ out.sort_by! { |x| x["name"] }
172
+ out.reverse!
145
173
  out
146
174
  end
147
175
 
@@ -0,0 +1,96 @@
1
+ (function(){"use strict";(()=>{try{const t=window.location.pathname||"";return t.startsWith("/__dev")||t.startsWith("/__feedback")}catch{return!1}})()?console.info("tina4-feedback-widget: skipping on developer path"):window.__tina4FeedbackLoaded?console.warn("tina4-feedback-widget already loaded; skipping"):(window.__tina4FeedbackLoaded=!0,b());function b(){const c=(getComputedStyle(document.documentElement).getPropertyValue("--primary")||"").trim()||"#3b82f6";h(c);const l=m();document.body.appendChild(l);let e=null,u;const r=[];l.addEventListener("click",()=>{if(e){e.remove(),e=null,l.style.display="";return}e=g(),document.body.appendChild(e),l.style.display="none",setTimeout(()=>e?.querySelector("textarea")?.focus(),0)});function g(){const o=document.createElement("div");o.className="tina4-fb-modal",o.innerHTML=`
2
+ <div class="tina4-fb-head">
3
+ <span class="tina4-fb-title">Tell us what's not working</span>
4
+ <button type="button" class="tina4-fb-close" aria-label="Close">×</button>
5
+ </div>
6
+ <div class="tina4-fb-context">
7
+ <span>📍 ${p(location.pathname+location.search)}</span>
8
+ <span>📐 ${window.innerWidth}×${window.innerHeight}</span>
9
+ </div>
10
+ <div class="tina4-fb-chat" role="log"></div>
11
+ <form class="tina4-fb-form">
12
+ <textarea
13
+ rows="3"
14
+ placeholder="What's hard to use here? Be specific — which field, which button, what you expected."
15
+ aria-label="Feedback message"
16
+ ></textarea>
17
+ <button type="submit" class="tina4-fb-send">Send</button>
18
+ </form>
19
+ `,o.querySelector(".tina4-fb-close")?.addEventListener("click",()=>{o.remove(),e=null,l.style.display=""});const a=o.querySelector("form");return a.addEventListener("submit",n=>{n.preventDefault();const i=a.querySelector("textarea"),f=i.value.trim();f&&(i.value="",x(f))}),s(o),o}function s(o){const a=o.querySelector(".tina4-fb-chat");if(a){if(!r.length){a.innerHTML=`<div class="tina4-fb-hint">Your feedback lands directly with the team — no email loop. We'll ask a quick follow-up if we need to.</div>`;return}a.innerHTML=r.map(n=>`<div class="tina4-fb-msg ${n.role==="user"?"tina4-fb-user":"tina4-fb-ai"}">${p(n.text)}</div>`).join(""),a.scrollTop=a.scrollHeight}}async function x(o){if(!e)return;r.push({role:"user",text:o}),s(e),d(e,!0);const a={message:o,context:{url:location.pathname+location.search,viewport:`${window.innerWidth}x${window.innerHeight}`,ua:navigator.userAgent},conversation_id:u};let n;try{const i=await fetch("/__feedback/api/turn",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)});if(n=await i.json(),!i.ok){const f=n?.error||`HTTP ${i.status}`;r.push({role:"ai",text:`Couldn't send: ${f}`}),s(e),d(e,!1);return}}catch(i){r.push({role:"ai",text:`Network issue: ${i?.message||i}`}),s(e),d(e,!1);return}if("ask"in n)u=n.conversation_id,r.push({role:"ai",text:n.ask}),s(e),d(e,!1),e?.querySelector("textarea")?.focus();else if("final"in n)r.push({role:"ai",text:`Thanks — filed as: "${n.final.title}". The team will take it from here.`}),s(e),d(e,!1),u=void 0,r.length=0,setTimeout(()=>{e?.remove(),e=null,l.style.display=""},4500);else{const i=n?.error||"unexpected response";r.push({role:"ai",text:`Issue: ${i}`}),s(e),d(e,!1)}}function d(o,a){const n=o.querySelector(".tina4-fb-send"),i=o.querySelector("textarea");n&&(n.disabled=a,n.textContent=a?"Sending…":"Send"),i&&(i.disabled=a)}}function m(){const t=document.createElement("button");return t.type="button",t.className="tina4-fb-btn",t.setAttribute("aria-label","Send feedback"),t.innerHTML="💬",t.title="Tell us what's not working",t}function h(t){const c=document.createElement("style");c.id="tina4-fb-styles",c.textContent=`
20
+ .tina4-fb-btn {
21
+ position: fixed; bottom: 1.25rem; right: 1.25rem;
22
+ width: 48px; height: 48px; border-radius: 50%; border: none;
23
+ background: ${t}; color: white; font-size: 1.4rem;
24
+ box-shadow: 0 4px 12px rgba(0,0,0,0.18); cursor: pointer;
25
+ z-index: 2147483646; transition: transform 0.15s, box-shadow 0.15s;
26
+ display: flex; align-items: center; justify-content: center;
27
+ line-height: 1; padding: 0;
28
+ }
29
+ .tina4-fb-btn:hover { transform: scale(1.06); box-shadow: 0 6px 16px rgba(0,0,0,0.22); }
30
+ .tina4-fb-btn:active { transform: scale(0.96); }
31
+ .tina4-fb-modal {
32
+ position: fixed; bottom: 5rem; right: 1.25rem;
33
+ width: 340px; max-height: 480px; display: flex; flex-direction: column;
34
+ background: #1e1e2e; color: #cdd6f4;
35
+ border: 1px solid #313244; border-radius: 8px;
36
+ box-shadow: 0 8px 32px rgba(0,0,0,0.35);
37
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
38
+ font-size: 0.85rem; z-index: 2147483647;
39
+ animation: tina4-fb-in 0.18s ease-out;
40
+ }
41
+ @keyframes tina4-fb-in {
42
+ from { opacity: 0; transform: translateY(8px); }
43
+ to { opacity: 1; transform: translateY(0); }
44
+ }
45
+ .tina4-fb-head {
46
+ display: flex; align-items: center; justify-content: space-between;
47
+ padding: 0.6rem 0.8rem; border-bottom: 1px solid #313244;
48
+ }
49
+ .tina4-fb-title { font-weight: 600; font-size: 0.9rem; }
50
+ .tina4-fb-close {
51
+ background: transparent; border: none; color: #9399b2;
52
+ font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.2rem;
53
+ }
54
+ .tina4-fb-close:hover { color: #cdd6f4; }
55
+ .tina4-fb-context {
56
+ display: flex; gap: 0.6rem; padding: 0.4rem 0.8rem;
57
+ font-size: 0.7rem; color: #9399b2;
58
+ border-bottom: 1px solid #313244;
59
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
60
+ }
61
+ .tina4-fb-chat {
62
+ flex: 1; overflow-y: auto; padding: 0.5rem 0.8rem;
63
+ display: flex; flex-direction: column; gap: 0.4rem;
64
+ min-height: 80px; max-height: 280px;
65
+ }
66
+ .tina4-fb-hint {
67
+ font-size: 0.75rem; color: #9399b2; line-height: 1.4; padding: 0.3rem 0;
68
+ }
69
+ .tina4-fb-msg {
70
+ padding: 0.4rem 0.6rem; border-radius: 6px;
71
+ max-width: 85%; word-wrap: break-word; line-height: 1.35;
72
+ }
73
+ .tina4-fb-user { align-self: flex-end; background: ${t}; color: white; }
74
+ .tina4-fb-ai { align-self: flex-start; background: #313244; }
75
+ .tina4-fb-form {
76
+ display: flex; flex-direction: column; gap: 0.4rem;
77
+ padding: 0.5rem 0.8rem 0.8rem; border-top: 1px solid #313244;
78
+ }
79
+ .tina4-fb-form textarea {
80
+ width: 100%; box-sizing: border-box; resize: vertical;
81
+ min-height: 60px; font-family: inherit; font-size: 0.82rem;
82
+ padding: 0.4rem 0.5rem; border: 1px solid #313244;
83
+ background: #11111b; color: #cdd6f4; border-radius: 4px;
84
+ line-height: 1.3;
85
+ }
86
+ .tina4-fb-form textarea:focus {
87
+ outline: none; border-color: ${t};
88
+ }
89
+ .tina4-fb-send {
90
+ align-self: flex-end; padding: 0.35rem 0.9rem;
91
+ background: ${t}; color: white; border: none; border-radius: 4px;
92
+ font-size: 0.8rem; font-weight: 500; cursor: pointer;
93
+ }
94
+ .tina4-fb-send:disabled { opacity: 0.55; cursor: wait; }
95
+ .tina4-fb-send:hover:not(:disabled) { filter: brightness(1.1); }
96
+ `,document.head.appendChild(c)}function p(t){return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}})();
@@ -75,6 +75,15 @@ module Tina4
75
75
  return dev_response if dev_response
76
76
  end
77
77
 
78
+ # Customer feedback widget routes (parity with Python's /__feedback/*
79
+ # surface — see tina4/feedback.rb). Always available — the master
80
+ # switch (TINA4_ENABLE_FEEDBACK) is enforced INSIDE handle_request
81
+ # so route shape stays stable across environments.
82
+ if path.start_with?("/__feedback")
83
+ fb_response = Tina4::Feedback.handle_request(env)
84
+ return fb_response if fb_response
85
+ end
86
+
78
87
  # Fast-path: API routes skip static file + swagger checks entirely
79
88
  unless path.start_with?("/api/")
80
89
  # Swagger
@@ -163,6 +172,33 @@ module Tina4
163
172
  end
164
173
  end
165
174
 
175
+ # Customer feedback widget injection — runs LAST so its <script>
176
+ # tag survives any earlier post-processing. No-op if disabled
177
+ # (TINA4_ENABLE_FEEDBACK off), the user isn't whitelisted, the
178
+ # path is /__dev or /__feedback, or the body isn't text/html with
179
+ # a closing </body> tag. Mirrors Python's server.py call site —
180
+ # see tina4_python/core/server.py around line 1543.
181
+ begin
182
+ status, headers, body_parts = rack_response
183
+ content_type = headers["content-type"] || ""
184
+ if content_type.include?("text/html") && body_parts.respond_to?(:join)
185
+ joined = body_parts.join
186
+ if joined.include?("</body>")
187
+ injected = Tina4::Feedback.inject_feedback_widget(
188
+ Struct.new(:path, :env).new(path, env),
189
+ joined
190
+ )
191
+ if injected != joined
192
+ new_headers = headers.dup
193
+ new_headers["content-length"] = injected.bytesize.to_s if new_headers["content-length"]
194
+ rack_response = [status, new_headers, [injected]]
195
+ end
196
+ end
197
+ end
198
+ rescue StandardError
199
+ # Injection is best-effort — never break the response.
200
+ end
201
+
166
202
  # Save session and set cookie if session was used
167
203
  if result && defined?(rack_response)
168
204
  status, headers, body_parts = rack_response
data/lib/tina4/request.rb CHANGED
@@ -75,13 +75,17 @@ module Tina4
75
75
  @body_parsed = nil
76
76
  end
77
77
 
78
- # Full URL reconstruction
78
+ # Full absolute URL — scheme://host[:port]/path[?query].
79
+ # Honours X-Forwarded-Proto / X-Forwarded-Host so apps behind a proxy
80
+ # still see the URL the client used. Matches Python/PHP/Node parity.
79
81
  def url
80
- scheme = env["rack.url_scheme"] || "http"
81
- host = env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
82
+ scheme = env["HTTP_X_FORWARDED_PROTO"] || env["rack.url_scheme"] || "http"
83
+ host = env["HTTP_X_FORWARDED_HOST"] || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
82
84
  port = env["SERVER_PORT"]
83
85
  url_str = "#{scheme}://#{host}"
84
- url_str += ":#{port}" if port && port != "80" && port != "443"
86
+ # Only append :port when the host doesn't already include one
87
+ # (HTTP_HOST often does) and it's not the default for the scheme.
88
+ url_str += ":#{port}" if port && !host.include?(":") && port.to_s != "80" && port.to_s != "443"
85
89
  url_str += @path
86
90
  url_str += "?#{@query_string}" unless @query_string.empty?
87
91
  url_str
data/lib/tina4/router.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
5
+
3
6
  module Tina4
4
7
  class Route
5
8
  attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
@@ -438,17 +441,79 @@ module Tina4
438
441
  GroupContext.new(prefix, auth_handler, middleware).instance_eval(&block)
439
442
  end
440
443
 
441
- # Load route files from a directory (file-based route discovery)
444
+ # Load route files from a directory (file-based route discovery).
445
+ #
446
+ # Idempotent: files already loaded by a previous call are skipped, so
447
+ # calling load_routes repeatedly (e.g. on /__dev/api/reload) only
448
+ # picks up NEW files. Records the directory so #rescan_routes! can
449
+ # re-run without re-passing it.
442
450
  def load_routes(directory)
443
451
  return unless Dir.exist?(directory)
444
- Dir.glob(File.join(directory, "**/*.rb")).sort.each do |file|
452
+
453
+ @loaded_route_files ||= {}
454
+ @last_routes_dir = directory
455
+
456
+ files = Dir.glob(File.join(directory, "**/*.rb")).sort
457
+ total = files.length
458
+ files.each do |file|
459
+ next if @loaded_route_files[file]
445
460
  begin
446
461
  load file
462
+ @loaded_route_files[file] = true
447
463
  Tina4::Log.debug("Route loaded: #{file}")
448
- rescue => e
464
+ rescue ScriptError, StandardError => e
465
+ # ScriptError catches SyntaxError, which is NOT a StandardError —
466
+ # a bare `rescue => e` would let a syntax-broken route file crash
467
+ # the whole discovery pass.
449
468
  Tina4::Log.error("Failed to load route #{file}: #{e.message}")
469
+ record_broken_route_import(file, e)
450
470
  end
451
471
  end
472
+
473
+ # Zero-routes warning — src/routes/ has .rb files but the router
474
+ # is still empty. Almost certainly the user forgot Tina4::Router.get.
475
+ if total > 0 && routes.empty?
476
+ Tina4::Log.warning(
477
+ "Auto-discover found #{total} .rb file(s) in #{directory} but no routes registered. " \
478
+ "Each route file must call Tina4::Router.get / .post / etc."
479
+ )
480
+ end
481
+ end
482
+
483
+ # Re-run the most recent load_routes — called by /__dev/api/reload so
484
+ # files dropped into src/routes/ after server boot get picked up
485
+ # without a restart. No-op if load_routes has never been called.
486
+ def rescan_routes!
487
+ return [] if @last_routes_dir.nil? || @last_routes_dir.empty?
488
+ before = routes.length
489
+ load_routes(@last_routes_dir)
490
+ added = routes.length - before
491
+ Tina4::Log.info("Re-discovered #{added} new route(s) on reload") if added.positive?
492
+ added
493
+ end
494
+
495
+ # Test-only helper — reset the loaded-files state so tests can scan
496
+ # the same directory multiple times with different file contents.
497
+ def reset_route_discovery!
498
+ @loaded_route_files = {}
499
+ @last_routes_dir = nil
500
+ end
501
+
502
+ # Write a .broken sentinel so /health and the dev dashboard surface
503
+ # auto-discover failures instead of swallowing them into a log line.
504
+ def record_broken_route_import(file, error)
505
+ broken_dir = File.join(Dir.pwd, "data", ".broken")
506
+ FileUtils.mkdir_p(broken_dir) unless Dir.exist?(broken_dir)
507
+ slug = file.gsub(%r{[/\\]}, "_")
508
+ payload = JSON.generate(
509
+ type: "auto_discover_failure",
510
+ file: file,
511
+ error: "#{error.class}: #{error.message}"
512
+ )
513
+ File.write(File.join(broken_dir, "discover_#{slug}.broken"), payload)
514
+ rescue StandardError
515
+ # If the .broken write itself fails, the original error is already
516
+ # in the log — nothing more to do.
452
517
  end
453
518
  end
454
519
 
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.12.9"
4
+ VERSION = "3.12.13"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -44,6 +44,7 @@ require_relative "tina4/events"
44
44
  require_relative "tina4/plan"
45
45
  require_relative "tina4/project_index"
46
46
  require_relative "tina4/dev_admin"
47
+ require_relative "tina4/feedback"
47
48
  require_relative "tina4/messenger"
48
49
  require_relative "tina4/dev_mailbox"
49
50
  require_relative "tina4/ai"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.9
4
+ version: 3.12.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-13 00:00:00.000000000 Z
11
+ date: 2026-05-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -306,6 +306,7 @@ files:
306
306
  - lib/tina4/env.rb
307
307
  - lib/tina4/error_overlay.rb
308
308
  - lib/tina4/events.rb
309
+ - lib/tina4/feedback.rb
309
310
  - lib/tina4/field_types.rb
310
311
  - lib/tina4/frond.rb
311
312
  - lib/tina4/gallery/auth/meta.json
@@ -337,6 +338,7 @@ files:
337
338
  - lib/tina4/orm.rb
338
339
  - lib/tina4/plan.rb
339
340
  - lib/tina4/project_index.rb
341
+ - lib/tina4/public/__feedback/widget.js
340
342
  - lib/tina4/public/css/tina4.css
341
343
  - lib/tina4/public/css/tina4.min.css
342
344
  - lib/tina4/public/favicon.ico