tina4ruby 3.11.19 → 3.11.35

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: 913dfbf6820b99da3ff8f0b5a845fb23239736ccc1db30eb958d664fba6abe9f
4
- data.tar.gz: c7c39ce01253b8dc60edc30550b004914ac9c4742ee2efabfdedab44dfe984f7
3
+ metadata.gz: 7dfb1cf2a3f34468c680f80f120b935a59348b7bab428fa5ba8c738a2a9ddf85
4
+ data.tar.gz: 7ceb6e23bc01350d5de21c77fd4d7902a2a314933a5290a6db3dac272060865a
5
5
  SHA512:
6
- metadata.gz: c926a2b39b248a45112e3f323174049ccdb0955c07ce3491a9346c837776cca37707ce54ecca21320ad71599b8d537a78eedadf4e675cd8c2b71dcfa6415ca39
7
- data.tar.gz: caf6b606d28154e7ed8ded6e2519cc4a45f10a9034644f15f82625eace4e2747dbc27d845dadf0f0881a1c180c38a465428976a7be6d7b1c52b4239f637fd793
6
+ metadata.gz: f27c8ed10bc2c080ed765060989dafdc1c05c04bb255ac7a093cda62b692965e940b81d46f9b8f170ef0e3afd9cee44ee63d82eadc09410c34ef29338f1192ec
7
+ data.tar.gz: 694ef83820ce2021da6ae51013259590fd70374ac2170831d039af3cb9dfa3a30ab1bf7ff691c4c8bcdaadefdf7e5ca9901c30a8a537b69ade99c8aaa8cb1c12
@@ -5,8 +5,6 @@ require "digest"
5
5
  require "tmpdir"
6
6
  require "net/http"
7
7
  require "uri"
8
- require "fileutils"
9
- require "shellwords"
10
8
  require_relative "metrics"
11
9
 
12
10
  module Tina4
@@ -316,55 +314,9 @@ module Tina4
316
314
  Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
317
315
  end
318
316
 
319
- # Write `.tina4/mcp.json` so MCP-aware tools (Claude Code, Cursor) can
320
- # auto-discover this project's live docs server. Idempotent — no-op if
321
- # the file already matches the desired contents. Also appends `.tina4/`
322
- # to the project's `.gitignore` if a git repo is present.
323
- def auto_discover_mcp!(project_root: Dir.pwd)
324
- return if @_mcp_auto_discovered
325
- @_mcp_auto_discovered = true
326
- return unless enabled?
327
-
328
- port = ENV["TINA4_PORT"] || ENV["PORT"] || "7147"
329
- url = "http://localhost:#{port}/__dev/api/mcp"
330
- target_dir = File.join(project_root, ".tina4")
331
- target = File.join(target_dir, "mcp.json")
332
- payload = {
333
- "mcpServers" => {
334
- "tina4-live-docs" => {
335
- "url" => url,
336
- "description" => "Live API docs for this Tina4 project (framework + user code)",
337
- },
338
- },
339
- }
340
- begin
341
- FileUtils.mkdir_p(target_dir)
342
- existing = if File.file?(target)
343
- (JSON.parse(File.read(target)) rescue {})
344
- else
345
- {}
346
- end
347
- if existing != payload
348
- File.write(target, JSON.pretty_generate(payload))
349
- end
350
-
351
- gitignore = File.join(project_root, ".gitignore")
352
- if File.directory?(File.join(project_root, ".git"))
353
- current = File.file?(gitignore) ? File.read(gitignore) : ""
354
- unless current.lines.map(&:strip).include?(".tina4/") || current.lines.map(&:strip).include?(".tina4")
355
- prefix = (!current.empty? && !current.end_with?("\n")) ? "\n" : ""
356
- File.write(gitignore, "#{current}#{prefix}.tina4/\n")
357
- end
358
- end
359
- rescue StandardError => e
360
- Tina4::Log.warning("auto_discover_mcp! failed: #{e.message}") if defined?(Tina4::Log)
361
- end
362
- end
363
-
364
317
  # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
365
318
  def handle_request(env)
366
319
  return nil unless enabled?
367
- auto_discover_mcp!
368
320
 
369
321
  path = env["PATH_INFO"] || "/"
370
322
  method = env["REQUEST_METHOD"]
@@ -453,9 +405,7 @@ module Tina4
453
405
  resolved = id ? error_tracker.resolve(id) : false
454
406
  json_response({ resolved: resolved, id: id })
455
407
  when ["POST", "/__dev/api/broken/clear"]
456
- # "Clear All" button — flush every tracked error, not only the
457
- # ones individually marked resolved. Matches PHP/Python.
458
- error_tracker.clear_all
408
+ error_tracker.clear_resolved
459
409
  json_response({ cleared: true })
460
410
  when ["GET", "/__dev/api/websockets"]
461
411
  json_response({ connections: [], count: 0 })
@@ -544,66 +494,6 @@ module Tina4
544
494
  when ["GET", "/__dev/api/metrics/file"]
545
495
  file_path = (query_param(env, "path") || "").to_s
546
496
  json_response(Tina4::Metrics.file_detail(file_path))
547
- when ["GET", "/__dev/api/thoughts"]
548
- json_response(thoughts_payload)
549
- when ["POST", "/__dev/api/supervise/create"]
550
- body = read_json_body(env) || {}
551
- json_response(proxy_supervisor("/supervise/create", method: "POST", body: body))
552
- when ["GET", "/__dev/api/supervise/sessions"]
553
- json_response(proxy_supervisor("/supervise/sessions", method: "GET", query: env["QUERY_STRING"]))
554
- when ["GET", "/__dev/api/supervise/diff"]
555
- json_response(proxy_supervisor("/supervise/diff", method: "GET", query: env["QUERY_STRING"]))
556
- when ["POST", "/__dev/api/supervise/commit"]
557
- body = read_json_body(env) || {}
558
- json_response(proxy_supervisor("/supervise/commit", method: "POST", body: body))
559
- when ["POST", "/__dev/api/supervise/cancel"]
560
- body = read_json_body(env) || {}
561
- json_response(proxy_supervisor("/supervise/cancel", method: "POST", body: body))
562
- when ["POST", "/__dev/api/execute"]
563
- body = read_json_body(env) || {}
564
- execute_proxy(body)
565
- when ["GET", "/__dev/api/files"]
566
- json_response(files_list(env))
567
- when ["GET", "/__dev/api/file"]
568
- json_response(file_read_payload(query_param(env, "path")))
569
- when ["GET", "/__dev/api/file/raw"]
570
- file_raw_response(query_param(env, "path"))
571
- when ["POST", "/__dev/api/file/save"]
572
- body = read_json_body(env) || {}
573
- json_response(file_save(body))
574
- when ["POST", "/__dev/api/file/rename"]
575
- body = read_json_body(env) || {}
576
- json_response(file_rename(body))
577
- when ["POST", "/__dev/api/file/delete"]
578
- body = read_json_body(env) || {}
579
- json_response(file_delete(body))
580
- when ["GET", "/__dev/api/deps/search"]
581
- json_response(deps_search(query_param(env, "q") || query_param(env, "query") || ""))
582
- when ["POST", "/__dev/api/deps/install"]
583
- body = read_json_body(env) || {}
584
- json_response(deps_install(body))
585
- when ["GET", "/__dev/api/git/status"]
586
- json_response(git_status_payload)
587
- when ["GET", "/__dev/api/mcp/tools"]
588
- json_response(mcp_tools_list)
589
- when ["POST", "/__dev/api/mcp/call"]
590
- body = read_json_body(env) || {}
591
- json_response(mcp_tool_call(body))
592
- when ["GET", "/__dev/api/scaffold"]
593
- json_response(scaffold_templates)
594
- when ["POST", "/__dev/api/scaffold/run"]
595
- body = read_json_body(env) || {}
596
- json_response(scaffold_run(body))
597
- when ["GET", "/__dev/api/docs/search"]
598
- json_response(docs_search_payload(env))
599
- when ["GET", "/__dev/api/docs/class"]
600
- json_response(docs_class_payload(query_param(env, "name")))
601
- when ["GET", "/__dev/api/docs/method"]
602
- json_response(docs_method_payload(query_param(env, "class"), query_param(env, "name")))
603
- when ["GET", "/__dev/api/docs/index"]
604
- json_response(docs_index_payload(query_param(env, "source")))
605
- when ["GET", "/__dev/api/docs/.well-known.json"]
606
- json_response(docs_well_known_payload)
607
497
  when ["GET", "/__dev/api/graphql/schema"]
608
498
  begin
609
499
  gql = Tina4::GraphQL.new
@@ -1040,358 +930,6 @@ module Tina4
1040
930
 
1041
931
  { deployed: name, files: copied }
1042
932
  end
1043
-
1044
- # ── New dev-admin surface area (parity with Python/PHP) ────
1045
-
1046
- def supervisor_base
1047
- base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
1048
- return base unless base.empty?
1049
- port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
1050
- "http://127.0.0.1:#{port}"
1051
- end
1052
-
1053
- def thoughts_payload
1054
- base = supervisor_base
1055
- begin
1056
- uri = URI.parse("#{base}/thoughts")
1057
- req = Net::HTTP::Get.new(uri)
1058
- resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) { |h| h.request(req) }
1059
- return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
1060
- { thoughts: [], error: "Supervisor returned #{resp.code}" }
1061
- rescue StandardError => e
1062
- { thoughts: [], error: e.message }
1063
- end
1064
- end
1065
-
1066
- def proxy_supervisor(path, method: "GET", body: nil, query: nil)
1067
- base = supervisor_base
1068
- url = "#{base}#{path}"
1069
- url += "?#{query}" if query && !query.empty?
1070
- begin
1071
- uri = URI.parse(url)
1072
- req = case method.upcase
1073
- when "POST"
1074
- r = Net::HTTP::Post.new(uri)
1075
- r["Content-Type"] = "application/json"
1076
- r.body = JSON.generate(body || {})
1077
- r
1078
- else
1079
- Net::HTTP::Get.new(uri)
1080
- end
1081
- resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 30) { |h| h.request(req) }
1082
- begin
1083
- JSON.parse(resp.body)
1084
- rescue JSON::ParserError
1085
- { body: resp.body, status: resp.code.to_i }
1086
- end
1087
- rescue StandardError => e
1088
- { error: e.message, supervisor: base }
1089
- end
1090
- end
1091
-
1092
- def execute_proxy(body)
1093
- # Proxy POST /execute to the supervisor at framework_port + 2000.
1094
- # Pass through the response stream as-is (SSE or JSON).
1095
- base = supervisor_base
1096
- begin
1097
- uri = URI.parse("#{base}/execute")
1098
- req = Net::HTTP::Post.new(uri)
1099
- req["Content-Type"] = "application/json"
1100
- req["Accept"] = "text/event-stream"
1101
- req.body = JSON.generate(body || {})
1102
- http = Net::HTTP.new(uri.host, uri.port)
1103
- http.open_timeout = 2
1104
- http.read_timeout = 300
1105
- resp = http.request(req)
1106
- ct = resp["content-type"] || "application/json; charset=utf-8"
1107
- [resp.code.to_i, { "content-type" => ct }, [resp.body.to_s]]
1108
- rescue StandardError => e
1109
- body_str = JSON.generate({ error: e.message, supervisor: base })
1110
- [502, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
1111
- end
1112
- end
1113
-
1114
- def safe_project_path(rel_path)
1115
- root = File.expand_path(Dir.pwd)
1116
- resolved = File.expand_path(rel_path.to_s, root)
1117
- raise ArgumentError, "path escapes project directory" unless resolved.start_with?(root)
1118
- resolved
1119
- end
1120
-
1121
- def files_list(env)
1122
- rel = query_param(env, "path") || "."
1123
- begin
1124
- target = safe_project_path(rel)
1125
- return { error: "Not found" } unless File.exist?(target)
1126
- return { error: "Not a directory" } unless File.directory?(target)
1127
- entries = Dir.children(target).sort.map do |name|
1128
- full = File.join(target, name)
1129
- {
1130
- name: name,
1131
- type: File.directory?(full) ? "dir" : "file",
1132
- size: File.file?(full) ? File.size(full) : 0
1133
- }
1134
- end
1135
- { path: rel, entries: entries, count: entries.size }
1136
- rescue => e
1137
- { error: e.message }
1138
- end
1139
- end
1140
-
1141
- def file_read_payload(rel)
1142
- return { error: "path required" } if rel.nil? || rel.empty?
1143
- begin
1144
- target = safe_project_path(rel)
1145
- return { error: "Not found" } unless File.exist?(target)
1146
- return { error: "Not a file" } unless File.file?(target)
1147
- content = File.read(target, encoding: "utf-8", invalid: :replace, undef: :replace)
1148
- { path: rel, content: content, bytes: File.size(target) }
1149
- rescue => e
1150
- { error: e.message }
1151
- end
1152
- end
1153
-
1154
- def file_raw_response(rel)
1155
- return json_response({ error: "path required" }) if rel.nil? || rel.empty?
1156
- begin
1157
- target = safe_project_path(rel)
1158
- return json_response({ error: "Not found" }) unless File.file?(target)
1159
- content = File.binread(target)
1160
- ct = case File.extname(target).downcase
1161
- when ".css" then "text/css"
1162
- when ".js" then "application/javascript"
1163
- when ".json" then "application/json"
1164
- when ".html", ".htm" then "text/html"
1165
- when ".png" then "image/png"
1166
- when ".jpg", ".jpeg" then "image/jpeg"
1167
- when ".gif" then "image/gif"
1168
- when ".svg" then "image/svg+xml"
1169
- else "text/plain; charset=utf-8"
1170
- end
1171
- [200, { "content-type" => ct }, [content]]
1172
- rescue => e
1173
- json_response({ error: e.message })
1174
- end
1175
- end
1176
-
1177
- def file_save(body)
1178
- rel = body["path"].to_s
1179
- content = body["content"].to_s
1180
- return { error: "path required" } if rel.empty?
1181
- begin
1182
- target = safe_project_path(rel)
1183
- existed = File.exist?(target)
1184
- FileUtils.mkdir_p(File.dirname(target))
1185
- File.write(target, content, encoding: "utf-8")
1186
- Tina4::Plan.record_action(existed ? "patched" : "created", rel) if defined?(Tina4::Plan)
1187
- { saved: rel, bytes: content.bytesize }
1188
- rescue => e
1189
- { error: e.message }
1190
- end
1191
- end
1192
-
1193
- def file_rename(body)
1194
- from = body["from"].to_s
1195
- to = body["to"].to_s
1196
- return { error: "from/to required" } if from.empty? || to.empty?
1197
- begin
1198
- src = safe_project_path(from)
1199
- dst = safe_project_path(to)
1200
- return { error: "Source not found" } unless File.exist?(src)
1201
- FileUtils.mkdir_p(File.dirname(dst))
1202
- File.rename(src, dst)
1203
- { renamed: { from: from, to: to } }
1204
- rescue => e
1205
- { error: e.message }
1206
- end
1207
- end
1208
-
1209
- def file_delete(body)
1210
- rel = body["path"].to_s
1211
- return { error: "path required" } if rel.empty?
1212
- begin
1213
- target = safe_project_path(rel)
1214
- return { error: "Not found" } unless File.exist?(target)
1215
- if File.directory?(target)
1216
- FileUtils.rm_rf(target)
1217
- else
1218
- File.delete(target)
1219
- end
1220
- { deleted: rel }
1221
- rescue => e
1222
- { error: e.message }
1223
- end
1224
- end
1225
-
1226
- def deps_search(query)
1227
- return { results: [], count: 0, error: "query required" } if query.to_s.strip.empty?
1228
- begin
1229
- uri = URI.parse("https://rubygems.org/api/v1/search.json?query=#{URI.encode_www_form_component(query)}")
1230
- http = Net::HTTP.new(uri.host, uri.port)
1231
- http.use_ssl = true
1232
- http.open_timeout = 5
1233
- http.read_timeout = 8
1234
- resp = http.request(Net::HTTP::Get.new(uri))
1235
- if resp.is_a?(Net::HTTPSuccess)
1236
- gems = JSON.parse(resp.body)
1237
- results = gems.first(20).map do |g|
1238
- { name: g["name"], version: g["version"], info: g["info"].to_s[0, 200] }
1239
- end
1240
- { results: results, count: results.size }
1241
- else
1242
- { results: [], count: 0, error: "rubygems returned #{resp.code}" }
1243
- end
1244
- rescue => e
1245
- { results: [], count: 0, error: e.message }
1246
- end
1247
- end
1248
-
1249
- def deps_install(body)
1250
- name = body["name"].to_s.strip
1251
- return { ok: false, error: "name required" } if name.empty?
1252
- # Append to Gemfile if not present — do NOT actually bundle install.
1253
- gemfile = File.join(Dir.pwd, "Gemfile")
1254
- return { ok: false, error: "No Gemfile at project root" } unless File.exist?(gemfile)
1255
- content = File.read(gemfile)
1256
- if content.include?("gem \"#{name}\"") || content.include?("gem '#{name}'")
1257
- return { ok: true, gem: name, note: "already in Gemfile" }
1258
- end
1259
- File.open(gemfile, "a") { |f| f.write("\ngem \"#{name}\"\n") }
1260
- { ok: true, gem: name, note: "added to Gemfile; run `bundle install`" }
1261
- end
1262
-
1263
- def git_status_payload
1264
- begin
1265
- inside = `cd #{Shellwords.escape(Dir.pwd)} && git rev-parse --is-inside-work-tree 2>/dev/null`.strip
1266
- return { error: "Not a git repository" } if inside != "true"
1267
- branch = `cd #{Shellwords.escape(Dir.pwd)} && git branch --show-current 2>/dev/null`.strip
1268
- status = `cd #{Shellwords.escape(Dir.pwd)} && git status --porcelain 2>/dev/null`.strip.split("\n").reject(&:empty?)
1269
- recent = `cd #{Shellwords.escape(Dir.pwd)} && git log --oneline -5 2>/dev/null`.strip.split("\n").reject(&:empty?)
1270
- { branch: branch, status: status, recent_commits: recent }
1271
- rescue => e
1272
- { error: "git unavailable: #{e.message}" }
1273
- end
1274
- end
1275
-
1276
- def mcp_tools_list
1277
- return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
1278
- server = Tina4._default_mcp_server
1279
- list = server.tools.values.map do |t|
1280
- { name: t["name"], description: t["description"], schema: t["inputSchema"] }
1281
- end
1282
- { tools: list, count: list.size }
1283
- end
1284
-
1285
- def mcp_tool_call(body)
1286
- tool_name = body["name"].to_s
1287
- args = body["arguments"] || {}
1288
- return { error: "tool name required" } if tool_name.empty?
1289
- return { error: "MCP not loaded" } unless defined?(Tina4::McpServer)
1290
- server = Tina4._default_mcp_server
1291
- payload = JSON.generate({
1292
- "jsonrpc" => "2.0",
1293
- "id" => 1,
1294
- "method" => "tools/call",
1295
- "params" => { "name" => tool_name, "arguments" => args }
1296
- })
1297
- raw = server.handle_message(payload)
1298
- return {} if raw.nil? || raw.empty?
1299
- JSON.parse(raw)
1300
- end
1301
-
1302
- def scaffold_templates
1303
- # Expose built-in scaffold targets for the dev-admin UI.
1304
- { templates: [
1305
- { id: "route", label: "Route file", target: "src/routes" },
1306
- { id: "model", label: "ORM model", target: "src/orm" },
1307
- { id: "migration", label: "SQL migration", target: "migrations" },
1308
- { id: "middleware", label: "Middleware class", target: "src/app" }
1309
- ] }
1310
- end
1311
-
1312
- def scaffold_run(body)
1313
- kind = body["kind"].to_s
1314
- name = body["name"].to_s.strip
1315
- return { ok: false, error: "kind + name required" } if kind.empty? || name.empty?
1316
- project = Dir.pwd
1317
- case kind
1318
- when "route"
1319
- target = File.join(project, "src", "routes", "#{name}.rb")
1320
- FileUtils.mkdir_p(File.dirname(target))
1321
- File.write(target, "# #{name} routes\nTina4::Router.get(\"/api/#{name}\") do |req, res|\n res.call({ hello: \"#{name}\" })\nend\n") unless File.exist?(target)
1322
- { ok: true, created: target.sub("#{project}/", "") }
1323
- when "model"
1324
- target = File.join(project, "src", "orm", "#{name}.rb")
1325
- FileUtils.mkdir_p(File.dirname(target))
1326
- cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1327
- File.write(target, "class #{cls} < Tina4::ORM\n integer_field :id, primary_key: true, auto_increment: true\n string_field :name\nend\n") unless File.exist?(target)
1328
- { ok: true, created: target.sub("#{project}/", "") }
1329
- when "migration"
1330
- ts = Time.now.strftime("%Y%m%d%H%M%S")
1331
- target = File.join(project, "migrations", "#{ts}_#{name}.sql")
1332
- FileUtils.mkdir_p(File.dirname(target))
1333
- File.write(target, "-- migration: #{name}\n")
1334
- { ok: true, created: target.sub("#{project}/", "") }
1335
- when "middleware"
1336
- target = File.join(project, "src", "app", "#{name}.rb")
1337
- FileUtils.mkdir_p(File.dirname(target))
1338
- cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1339
- File.write(target, "class #{cls}\n def self.before_check(req, res); [req, res]; end\nend\n") unless File.exist?(target)
1340
- { ok: true, created: target.sub("#{project}/", "") }
1341
- else
1342
- { ok: false, error: "unknown kind: #{kind}" }
1343
- end
1344
- end
1345
-
1346
- # ── Live Docs (Live API RAG) ─────────────────────────────────
1347
-
1348
- def docs_search_payload(env)
1349
- q = (query_param(env, "q") || "").to_s
1350
- k = (query_param(env, "k") || "5").to_i
1351
- source = (query_param(env, "source") || "all").to_s
1352
- include_private = %w[1 true yes].include?((query_param(env, "include_private") || "").to_s.downcase)
1353
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1354
- results = Tina4::Docs.cached(Dir.pwd).search(
1355
- q, k: k, source: source, include_private: include_private
1356
- )
1357
- took_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0).round(2)
1358
- { ok: true, query: q, results: results, took_ms: took_ms }
1359
- end
1360
-
1361
- def docs_class_payload(name)
1362
- spec = Tina4::Docs.cached(Dir.pwd).class_spec(name.to_s)
1363
- return { ok: false, error: "class not found: #{name}" } if spec.nil?
1364
- { ok: true, class: spec }
1365
- end
1366
-
1367
- def docs_method_payload(class_fqn, name)
1368
- spec = Tina4::Docs.cached(Dir.pwd).method_spec(class_fqn.to_s, name.to_s)
1369
- return { ok: false, error: "method not found: #{class_fqn}##{name}" } if spec.nil?
1370
- { ok: true, method: spec }
1371
- end
1372
-
1373
- def docs_index_payload(source)
1374
- idx = Tina4::Docs.cached(Dir.pwd).index
1375
- idx = idx.select { |e| e[:source] == source } if source && %w[framework user vendor].include?(source.to_s)
1376
- { ok: true, count: idx.size, entries: idx }
1377
- end
1378
-
1379
- def docs_well_known_payload
1380
- {
1381
- ok: true,
1382
- service: "tina4-live-docs",
1383
- version: Tina4::VERSION,
1384
- framework: "tina4-ruby",
1385
- endpoints: {
1386
- search: "/__dev/api/docs/search?q=<query>&k=<int>&source=<framework|user|all>&include_private=<bool>",
1387
- class: "/__dev/api/docs/class?name=<fqn>",
1388
- method: "/__dev/api/docs/method?class=<fqn>&name=<method>",
1389
- index: "/__dev/api/docs/index?source=<framework|user|all>",
1390
- },
1391
- mcp: { url: "/__dev/api/mcp", tools: %w[api_search api_class api_method] },
1392
- spec: "https://tina4.com — Live API RAG plan/v3/22-LIVE-API-RAG.md",
1393
- }
1394
- end
1395
933
  end
1396
934
  end
1397
935
  end
@@ -5,6 +5,20 @@ module Tina4
5
5
  class FirebirdDriver
6
6
  attr_reader :connection
7
7
 
8
+ # Substring markers (lowercased) that identify a dead-socket Firebird
9
+ # error worth reconnecting for. Idle Firebird connections die silently
10
+ # behind NAT timeouts, server-side ConnectionIdleTimeout, or Docker
11
+ # network rotation; without this the next prepare crashes the request.
12
+ DEAD_CONN_MARKERS = [
13
+ "error writing data to the connection",
14
+ "error reading data from the connection",
15
+ "connection shutdown",
16
+ "connection lost",
17
+ "network error",
18
+ "connection is not active",
19
+ "broken pipe"
20
+ ].freeze
21
+
8
22
  def connect(connection_string, username: nil, password: nil)
9
23
  require "fb"
10
24
  require "uri"
@@ -21,10 +35,13 @@ module Tina4
21
35
  db_path || connection_string.sub(/^firebird:\/\//, "")
22
36
  end
23
37
 
24
- opts = { database: database }
25
- opts[:username] = db_user if db_user
26
- opts[:password] = db_pass if db_pass
27
- @connection = Fb::Database.new(**opts).connect
38
+ # Cache for transparent reconnect — never logged, lives only in
39
+ # driver memory alongside the connection it owns.
40
+ @connect_opts = { database: database }
41
+ @connect_opts[:username] = db_user if db_user
42
+ @connect_opts[:password] = db_pass if db_pass
43
+
44
+ open_connection
28
45
  rescue LoadError
29
46
  raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
30
47
  end
@@ -34,22 +51,35 @@ module Tina4
34
51
  end
35
52
 
36
53
  def execute_query(sql, params = [])
37
- rows = if params.empty?
38
- @connection.query(:hash, sql)
39
- else
40
- @connection.query(:hash, sql, *params)
41
- end
54
+ rows = with_reconnect do
55
+ if params.empty?
56
+ @connection.query(:hash, sql)
57
+ else
58
+ @connection.query(:hash, sql, *params)
59
+ end
60
+ end
42
61
  rows.map { |row| decode_blobs(stringify_keys(row)) }
43
62
  end
44
63
 
45
64
  def execute(sql, params = [])
46
- if params.empty?
47
- @connection.execute(sql)
48
- else
49
- @connection.execute(sql, *params)
65
+ with_reconnect do
66
+ if params.empty?
67
+ @connection.execute(sql)
68
+ else
69
+ @connection.execute(sql, *params)
70
+ end
50
71
  end
51
72
  end
52
73
 
74
+ # Public so specs (and curious operators) can verify the matcher
75
+ # behaviour without poking private methods.
76
+ def self.dead_connection?(error_or_message)
77
+ msg = error_or_message.respond_to?(:message) ? error_or_message.message : error_or_message.to_s
78
+ return false if msg.nil? || msg.empty?
79
+ lower = msg.downcase
80
+ DEAD_CONN_MARKERS.any? { |m| lower.include?(m) }
81
+ end
82
+
53
83
  def last_insert_id
54
84
  nil
55
85
  end
@@ -103,6 +133,34 @@ module Tina4
103
133
 
104
134
  private
105
135
 
136
+ def open_connection
137
+ @connection = Fb::Database.new(**@connect_opts).connect
138
+ end
139
+
140
+ # Force-close a stale handle and reopen using cached opts. Idempotent —
141
+ # safe to call when the connection is already gone.
142
+ def reconnect!
143
+ begin
144
+ @connection&.close
145
+ rescue StandardError
146
+ # connection already gone — nothing to clean up
147
+ end
148
+ @connection = nil
149
+ @transaction = nil
150
+ open_connection
151
+ end
152
+
153
+ # Run a block; if it raises with a dead-connection signature, reconnect
154
+ # once and retry. Skipped inside an explicit transaction — atomicity
155
+ # beats resilience there; the caller handles rollback.
156
+ def with_reconnect
157
+ yield
158
+ rescue StandardError => e
159
+ raise unless self.class.dead_connection?(e) && @transaction.nil?
160
+ reconnect!
161
+ yield
162
+ end
163
+
106
164
  def stringify_keys(hash)
107
165
  hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
108
166
  end