tina4ruby 3.11.32 → 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.
@@ -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
data/lib/tina4/frond.rb CHANGED
@@ -548,20 +548,6 @@ module Tina4
548
548
  value = eval_expr(var_name, context)
549
549
  filters.each do |fname, args|
550
550
  next if fname == "raw" || fname == "safe"
551
-
552
- # Filter + property-access chain: `first.groupSummary` — apply
553
- # the filter, then traverse the path on the result using a
554
- # synthetic context so eval_expr's dotted resolution does the
555
- # work. Parity with tina4-python + tina4-php.
556
- real_fname, tail_path = split_filter_name_and_path(fname)
557
- if !tail_path.empty? && @filters[real_fname]
558
- evaluated_args = args.map { |a| eval_filter_arg(a, context) }
559
- value = @filters[real_fname].call(value, *evaluated_args)
560
- value = eval_expr("__frond_filter_tmp.#{tail_path}",
561
- { "__frond_filter_tmp" => value })
562
- next
563
- end
564
-
565
551
  fn = @filters[fname]
566
552
  if fn
567
553
  evaluated_args = args.map { |a| eval_filter_arg(a, context) }
@@ -625,19 +611,6 @@ module Tina4
625
611
  next
626
612
  end
627
613
 
628
- # Filter + property-access chain: `first.groupSummary` — apply
629
- # the filter, then traverse the path on the result. Done BEFORE
630
- # the inline fast-path so cases like `items|first.name` work
631
- # regardless of whether `first` is an inline filter too.
632
- real_fname, tail_path = split_filter_name_and_path(fname)
633
- if !tail_path.empty? && @filters[real_fname]
634
- evaluated_args = args.map { |a| eval_filter_arg(a, context) }
635
- value = @filters[real_fname].call(value, *evaluated_args)
636
- value = eval_expr("__frond_filter_tmp.#{tail_path}",
637
- { "__frond_filter_tmp" => value })
638
- next
639
- end
640
-
641
614
  # Inline common no-arg filters for speed (skip generic dispatch)
642
615
  if args.empty? && INLINE_FILTERS.include?(fname)
643
616
  value = case fname
@@ -783,41 +756,6 @@ module Tina4
783
756
  # Filter chain parser
784
757
  # -----------------------------------------------------------------------
785
758
 
786
- # Split "first.groupSummary" into ["first", "groupSummary"] so a
787
- # filter segment followed by property access — `{{ x | first.name }}`
788
- # — applies the filter then traverses the path on the result.
789
- # Returns [fname, ""] when no structural dot is present.
790
- #
791
- # The split point must sit outside parens/brackets/braces and quotes
792
- # so filter args like `round(1.5)` or `date("Y.m.d")` don't false-
793
- # trigger. Parity with tina4-python and tina4-php.
794
- def split_filter_name_and_path(fname)
795
- depth = 0
796
- in_q = nil
797
- i = 0
798
- n = fname.length
799
- while i < n
800
- ch = fname[i]
801
- if in_q
802
- in_q = nil if ch == in_q && (i.zero? || fname[i - 1] != "\\")
803
- i += 1
804
- next
805
- end
806
- case ch
807
- when '"', "'"
808
- in_q = ch
809
- when "(", "[", "{"
810
- depth += 1
811
- when ")", "]", "}"
812
- depth -= 1
813
- when "."
814
- return [fname[0...i], fname[(i + 1)..]] if depth.zero?
815
- end
816
- i += 1
817
- end
818
- [fname, ""]
819
- end
820
-
821
759
  def parse_filter_chain(expr)
822
760
  cached = @filter_chain_cache[expr]
823
761
  return cached if cached
data/lib/tina4/mcp.rb CHANGED
@@ -680,196 +680,6 @@ module Tina4
680
680
  end
681
681
  }, "Seed a table with fake data")
682
682
 
683
- # ── File patch ────────────────────────────────────
684
- server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
685
- p = safe_path.call(path)
686
- return { "error" => "File not found: #{path}" } unless File.file?(p)
687
- original = File.read(p, encoding: "utf-8")
688
- occurrences = original.scan(old_string).size
689
- return { "error" => "old_string not found in #{path}" } if occurrences.zero?
690
- if occurrences != count.to_i
691
- return { "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." }
692
- end
693
- updated = original.sub(old_string, new_string)
694
- # Ruby String#sub replaces first; if count > 1, do N replacements
695
- if count.to_i > 1
696
- updated = original.dup
697
- count.to_i.times { updated.sub!(old_string, new_string) }
698
- end
699
- File.write(p, updated, encoding: "utf-8")
700
- rel = p.sub("#{project_root}/", "")
701
- Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
702
- { "patched" => rel, "replacements" => count.to_i, "bytes" => updated.bytesize }
703
- }, "Targeted edit: replace old_string with new_string in a file")
704
-
705
- # ── Docs tools ────────────────────────────────────
706
- framework_doc_paths = lambda do
707
- gem_root = File.expand_path("..", File.dirname(__FILE__))
708
- candidates = [
709
- File.join(gem_root, "..", "CLAUDE.md"),
710
- File.join(gem_root, "..", "AGENTS.md"),
711
- File.join(gem_root, "..", "CONVENTIONS.md"),
712
- File.join(gem_root, "..", "README.md"),
713
- File.join(Dir.pwd, "CLAUDE.md")
714
- ]
715
- candidates.map { |p| File.expand_path(p) }.uniq.select { |p| File.file?(p) }
716
- end
717
-
718
- server.register_tool("docs_list", lambda {
719
- framework_doc_paths.call.map { |p| { "name" => File.basename(p), "bytes" => File.size(p) } }
720
- }, "List framework documentation files")
721
-
722
- server.register_tool("docs_search", lambda { |query:, limit: 5, context_lines: 4|
723
- return { "error" => "query must be at least 2 characters" } if query.to_s.length < 2
724
- needle = query.to_s.downcase
725
- hits = []
726
- framework_doc_paths.call.each do |p|
727
- begin
728
- lines = File.read(p, encoding: "utf-8", invalid: :replace, undef: :replace).split("\n")
729
- rescue StandardError
730
- next
731
- end
732
- lines.each_with_index do |line, i|
733
- next unless line.downcase.include?(needle)
734
- start_i = [0, i - context_lines.to_i].max
735
- end_i = [lines.size, i + context_lines.to_i + 1].min
736
- score = 1
737
- score += 1 if line.include?(query.to_s)
738
- score += 2 if line.lstrip.start_with?("#")
739
- hits << {
740
- "file" => File.basename(p),
741
- "line" => i + 1,
742
- "score" => score,
743
- "snippet" => lines[start_i...end_i].join("\n")
744
- }
745
- end
746
- end
747
- hits.sort_by! { |h| -h["score"] }
748
- hits.first([1, limit.to_i].max)
749
- }, "Search Tina4 framework docs for a query string")
750
-
751
- server.register_tool("docs_section", lambda { |file:, heading:|
752
- match = framework_doc_paths.call.find { |p| File.basename(p) == file }
753
- return { "error" => "Unknown doc file: #{file}. Try docs_list() first." } unless match
754
- text = File.read(match, encoding: "utf-8", invalid: :replace, undef: :replace)
755
- lines = text.split("\n")
756
- heading_lc = heading.to_s.downcase.strip
757
- start_i = -1
758
- start_level = 0
759
- lines.each_with_index do |line, i|
760
- stripped = line.lstrip
761
- next unless stripped.start_with?("#")
762
- level = stripped.length - stripped.sub(/\A#+/, "").length
763
- title = stripped[level..].to_s.strip.downcase
764
- if title.include?(heading_lc)
765
- start_i = i
766
- start_level = level
767
- break
768
- end
769
- end
770
- return { "error" => "Heading '#{heading}' not found in #{file}" } if start_i < 0
771
- end_i = lines.size
772
- (start_i + 1).upto(lines.size - 1) do |j|
773
- stripped = lines[j].lstrip
774
- next unless stripped.start_with?("#")
775
- level = stripped.length - stripped.sub(/\A#+/, "").length
776
- if level <= start_level
777
- end_i = j
778
- break
779
- end
780
- end
781
- { "file" => file, "heading" => lines[start_i].strip, "body" => lines[start_i...end_i].join("\n") }
782
- }, "Return a full markdown section from a framework doc file")
783
-
784
- # ── Git / deps / project ──────────────────────────
785
- server.register_tool("git_status", lambda {
786
- Tina4::DevAdmin.send(:git_status_payload)
787
- }, "Show git branch, modified/untracked files, recent commits")
788
-
789
- server.register_tool("deps_list", lambda {
790
- gemfile = File.join(Dir.pwd, "Gemfile")
791
- return { "error" => "No Gemfile at project root" } unless File.file?(gemfile)
792
- deps = File.read(gemfile).scan(/^\s*gem\s+["']([^"']+)["']/).flatten
793
- { "name" => File.basename(Dir.pwd), "dependencies" => deps }
794
- }, "List this project's declared Ruby dependencies")
795
-
796
- server.register_tool("project_overview", lambda {
797
- { "system" => { "framework" => "tina4-ruby", "version" => (defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"), "ruby" => RUBY_DESCRIPTION, "cwd" => project_root } }
798
- }, "One-shot snapshot: system + project info")
799
-
800
- # ── Project index ─────────────────────────────────
801
- server.register_tool("index_rebuild", lambda {
802
- Tina4::ProjectIndex.refresh
803
- }, "Refresh the persistent project index (lazy, mtime-based)")
804
-
805
- server.register_tool("index_search", lambda { |query:, limit: 20|
806
- Tina4::ProjectIndex.search(query, limit.to_i)
807
- }, "Find files by path, symbol, route, or summary")
808
-
809
- server.register_tool("index_file", lambda { |path:|
810
- Tina4::ProjectIndex.file_entry(path)
811
- }, "Full index entry for one file")
812
-
813
- server.register_tool("index_overview", lambda {
814
- Tina4::ProjectIndex.overview
815
- }, "Project shape: files by language, routes, models, recent edits")
816
-
817
- # ── Plan management ───────────────────────────────
818
- server.register_tool("plan_current", lambda {
819
- Tina4::Plan.current
820
- }, "The active plan: title, steps (done/not), next step, progress")
821
-
822
- server.register_tool("plan_list", lambda {
823
- Tina4::Plan.list_plans
824
- }, "All plans in plan/ with progress and which one is active")
825
-
826
- server.register_tool("plan_create", lambda { |title:, goal: "", steps: nil, make_current: true|
827
- Tina4::Plan.create(title, goal: goal, steps: steps, make_current: make_current)
828
- }, "Create a new markdown plan in plan/ and make it active")
829
-
830
- server.register_tool("plan_switch_to", lambda { |name:|
831
- Tina4::Plan.set_current(name)
832
- }, "Make a different plan the active one")
833
-
834
- server.register_tool("plan_complete_step", lambda { |index:|
835
- Tina4::Plan.complete_step(index.to_i)
836
- }, "Tick a step as done (call the moment the step finishes)")
837
-
838
- server.register_tool("plan_add_step", lambda { |text:|
839
- Tina4::Plan.add_step(text)
840
- }, "Append a new unchecked step to the current plan")
841
-
842
- server.register_tool("plan_note", lambda { |text:|
843
- Tina4::Plan.append_note(text)
844
- }, "Append a timestamped note/breadcrumb to the current plan")
845
-
846
- server.register_tool("plan_archive", lambda { |name: ""|
847
- Tina4::Plan.archive(name)
848
- }, "Move a finished plan to plan/done/")
849
-
850
- server.register_tool("plan_read", lambda { |name:|
851
- Tina4::Plan.read(name)
852
- }, "Full structured view of any plan by filename")
853
-
854
- server.register_tool("plan_flesh", lambda { |name: "", prompt: ""|
855
- Tina4::Plan.flesh(name, prompt)
856
- }, "Auto-generate concrete build steps via AI and append them to an existing plan")
857
-
858
- # ── Live API Docs (Live API RAG) ──────────────────
859
- server.register_tool("api_search", lambda { |query:, k: 5, source: "all", include_private: false|
860
- Tina4::Docs.cached(project_root).search(
861
- query.to_s, k: k.to_i, source: source.to_s, include_private: include_private == true || include_private.to_s == "true"
862
- )
863
- }, "Search the live API index (framework + user code) for ranked hits")
864
-
865
- server.register_tool("api_class", lambda { |name:|
866
- Tina4::Docs.cached(project_root).class_spec(name.to_s)
867
- }, "Full class reflection (methods, file, line) from the live API index")
868
-
869
- server.register_tool("api_method", lambda { |class_name:, name:|
870
- Tina4::Docs.cached(project_root).method_spec(class_name.to_s, name.to_s)
871
- }, "Single method spec (signature, summary, file, line) from the live API index")
872
-
873
683
  # ── System Tools ──────────────────────────────────
874
684
  server.register_tool("system_info", lambda {
875
685
  {