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 +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 +36 -0
- data/lib/tina4/request.rb +8 -4
- data/lib/tina4/router.rb +68 -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
|
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,
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
+
|
|
725
935
|
Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}})();
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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-
|
|
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
|