tina4ruby 3.13.37 → 3.13.39

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.
data/lib/tina4/log.rb CHANGED
@@ -12,11 +12,12 @@ module Tina4
12
12
  "[TINA4_LOG_INFO]" => 1,
13
13
  "[TINA4_LOG_WARNING]" => 2,
14
14
  "[TINA4_LOG_ERROR]" => 3,
15
- "[TINA4_LOG_NONE]" => 4
15
+ "[TINA4_LOG_CRITICAL]" => 4,
16
+ "[TINA4_LOG_NONE]" => 5
16
17
  }.freeze
17
18
 
18
19
  SEVERITY_MAP = {
19
- debug: 0, info: 1, warn: 2, error: 3
20
+ debug: 0, info: 1, warn: 2, error: 3, critical: 4
20
21
  }.freeze
21
22
 
22
23
  COLORS = {
@@ -65,15 +66,36 @@ module Tina4
65
66
  @format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
66
67
  @json_mode = @format == "json"
67
68
 
68
- # TINA4_LOG_OUTPUT — "stdout", "file", or "both". Defaults to "both".
69
+ # TINA4_LOG_OUTPUT — "stdout", "file", or "both".
70
+ #
71
+ # Default (UNSET): stdout is ALWAYS on. The log FILE (tina4.log + any
72
+ # error log) is written ONLY in development — i.e. when TINA4_DEBUG is
73
+ # truthy. In production / containers (TINA4_DEBUG falsy) the logger is
74
+ # stdout-only: writing a log file inside a container just bloats the
75
+ # writable layer + disk, and 12-factor wants logs on stdout for the
76
+ # platform to capture. An explicit TINA4_LOG_OUTPUT=file/both (or an
77
+ # explicit TINA4_LOG_FILE path) overrides this and STILL writes a file.
78
+ # Mirrors the Python master (debug/__init__.py configure()).
79
+ # An explicit TINA4_LOG_FILE always wins: a path the operator named must
80
+ # be written even in production (parity with the Python master, where an
81
+ # explicit log_file builds a writer unconditionally), so the dev-gated
82
+ # default below resolves to "both" (stdout + file) rather than "stdout".
83
+ explicit_file = !(log_file_env.nil? || log_file_env.empty?)
84
+ default_output = if explicit_file || truthy?(ENV["TINA4_DEBUG"])
85
+ "both"
86
+ else
87
+ "stdout"
88
+ end
69
89
  output_env = ENV["TINA4_LOG_OUTPUT"]
70
- @output = output_env && !output_env.empty? ? output_env.downcase : "both"
71
- unless %w[stdout file both].include?(@output)
72
- @output = "both"
73
- end
90
+ @output = if output_env && !output_env.empty?
91
+ output_env.downcase
92
+ else
93
+ default_output
94
+ end
95
+ @output = default_output unless %w[stdout file both].include?(@output)
74
96
 
75
- # TINA4_LOG_CRITICAL — when true, raise on log write failures instead of swallowing.
76
- @critical = truthy?(ENV["TINA4_LOG_CRITICAL"])
97
+ # TINA4_LOG_STRICT — when true, raise on log write failures instead of swallowing.
98
+ @strict = truthy?(ENV["TINA4_LOG_STRICT"])
77
99
 
78
100
  @console_level = resolve_level
79
101
  @request_id = nil
@@ -122,6 +144,28 @@ module Tina4
122
144
  @json_mode
123
145
  end
124
146
 
147
+ # Would a message at `level` pass the configured MINIMUM CONSOLE LEVEL
148
+ # (TINA4_LOG_LEVEL)? Returns true iff `log` would print it to stdout —
149
+ # it reflects CONSOLE visibility only. The log FILE records every level
150
+ # regardless of this threshold, so this never gates file output.
151
+ #
152
+ # `level` accepts a String or Symbol and is case-insensitive
153
+ # ("INFO", :info, "Warning", :warning all work). Mirrors Python's
154
+ # Log.is_enabled. It REUSES the exact severity >= @console_level
155
+ # comparison the console branch in `log` uses (line ~167) via
156
+ # SEVERITY_MAP / resolve_level — it never re-implements level
157
+ # comparison, so it can never disagree with what the logger prints.
158
+ #
159
+ # "critical" is a FIRST-CLASS top-level severity (4 — above error 3),
160
+ # not a parity alias for error. It is evaluated with ordinary threshold
161
+ # logic (critical 4 >= @console_level), so it passes at every level
162
+ # except none (5) — matching the Python master.
163
+ def enabled?(level)
164
+ sym = normalize_level(level)
165
+ severity = SEVERITY_MAP[sym] || 0
166
+ severity >= console_level
167
+ end
168
+
125
169
  def info(message, context = {})
126
170
  log(:info, message, context)
127
171
  end
@@ -138,6 +182,14 @@ module Tina4
138
182
  log(:error, message, context)
139
183
  end
140
184
 
185
+ # critical is the HIGHEST severity (4, above error). Like every other
186
+ # level it ALWAYS emits, subject only to the TINA4_LOG_LEVEL threshold
187
+ # (which critical passes at every level except none). A critical log is
188
+ # never a silent no-op. Mirrors the Python master.
189
+ def critical(message, context = {})
190
+ log(:critical, message, context)
191
+ end
192
+
141
193
  # Test/teardown helper — closes the underlying Logger so the file
142
194
  # handle is released (Windows / tmpdir cleanup).
143
195
  def close_file_logger
@@ -181,6 +233,28 @@ module Tina4
181
233
  @current_context = {}
182
234
  end
183
235
 
236
+ # The current minimum console level as an integer (the same value
237
+ # the console branch in `log` compares against). Ensures the logger
238
+ # is configured so `enabled?` works before any log call has run.
239
+ def console_level
240
+ configure unless @initialized
241
+ @console_level
242
+ end
243
+
244
+ # Map a level (String or Symbol, case-insensitive) onto the symbol
245
+ # space used by SEVERITY_MAP. Accepts the public method names
246
+ # (debug/info/warning/error/critical) and the internal :warn symbol.
247
+ # critical is a FIRST-CLASS level (severity 4), not an alias for error.
248
+ # Unknown levels fall through to their own symbol and resolve to
249
+ # severity 0 in `enabled?`.
250
+ def normalize_level(level)
251
+ sym = level.to_s.strip.downcase.to_sym
252
+ case sym
253
+ when :warning then :warn
254
+ else sym
255
+ end
256
+ end
257
+
184
258
  def resolve_level
185
259
  # v3.13.14: default is INFO (was ALL) so a deployed app surfaces
186
260
  # request/startup/warn/error without debug noise, matching
@@ -199,6 +273,7 @@ module Tina4
199
273
  when :info then "INFO"
200
274
  when :warn then "WARNING"
201
275
  when :error then "ERROR"
276
+ when :critical then "CRITICAL"
202
277
  else level.to_s.upcase
203
278
  end
204
279
  end
@@ -278,6 +353,7 @@ module Tina4
278
353
  when :info then COLORS[:green]
279
354
  when :warn then COLORS[:yellow]
280
355
  when :error then COLORS[:red]
356
+ when :critical then COLORS[:magenta]
281
357
  else COLORS[:reset]
282
358
  end
283
359
  "#{color}#{line}#{COLORS[:reset]}"
@@ -288,7 +364,7 @@ module Tina4
288
364
  # Use << to bypass Logger's severity filtering — we already filtered above.
289
365
  @file_logger << "#{line}\n"
290
366
  rescue IOError, SystemCallError => e
291
- raise if @critical
367
+ raise if @strict
292
368
  # Don't crash on log write failure
293
369
  end
294
370
  end
data/lib/tina4/mcp.rb CHANGED
@@ -139,15 +139,35 @@ module Tina4
139
139
 
140
140
  # Resolve whether the built-in MCP dev server should be active.
141
141
  #
142
- # Precedence:
143
- # * TINA4_MCP set explicitly → use that (truthy/falsey).
144
- # * Otherwise: enabled only when TINA4_DEBUG=true.
142
+ # Resolution order (highest priority first):
143
+ # 1. TINA4_MCP set explicitly → use that (truthy/falsey). Honoured on ANY
144
+ # host. An explicit `true` is how a sysadmin opts a remote /
145
+ # debug-disabled deployment in (e.g. for a remote AI assistant); an
146
+ # explicit `false` force-disables it everywhere.
147
+ # 2. TINA4_DEBUG=true → implicit on for dev, but LOCALHOST-ONLY unless
148
+ # TINA4_MCP_REMOTE=true. The MCP dev tools expose powerful operations
149
+ # (DB query, file read/WRITE, route listing), so they never auto-expose
150
+ # on a non-localhost host without an explicit opt-in.
151
+ # 3. Otherwise off.
152
+ #
153
+ # Mirrors the Python master (tina4_python/mcp/__init__.py is_enabled). Before
154
+ # v3.13.39 is_localhost? was dead code and TINA4_MCP_REMOTE was never read, so
155
+ # the documented localhost guard was not actually enforced — a non-localhost
156
+ # TINA4_DEBUG=true deployment auto-exposed the dev tools. This wires it.
145
157
  def self.mcp_enabled?
146
158
  explicit = ENV["TINA4_MCP"]
147
159
  if explicit && !explicit.empty?
148
- return %w[true 1 yes on].include?(explicit.to_s.strip.downcase)
160
+ return truthy?(explicit)
149
161
  end
150
- %w[true 1 yes on].include?(ENV.fetch("TINA4_DEBUG", "").to_s.strip.downcase)
162
+ return false unless truthy?(ENV["TINA4_DEBUG"])
163
+
164
+ # Dev auto-enable: localhost only, unless explicitly opted into remote.
165
+ is_localhost? || truthy?(ENV["TINA4_MCP_REMOTE"])
166
+ end
167
+
168
+ # Case-insensitive truthiness for env values: true/1/yes/on.
169
+ def self.truthy?(val)
170
+ %w[true 1 yes on].include?(val.to_s.strip.downcase)
151
171
  end
152
172
 
153
173
  # Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps
@@ -651,9 +671,16 @@ module Tina4
651
671
  db = Tina4.database
652
672
  return { "error" => "No database connection" } if db.nil?
653
673
  param_list = params.is_a?(String) ? JSON.parse(params) : params
654
- result = db.execute(sql, param_list)
655
- db.commit rescue nil
656
- { "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
674
+ # db.execute() now RAISES on a SQL error (it no longer returns false).
675
+ # Catch it and return a clean { error: } payload instead of letting the
676
+ # exception escape the tool handler.
677
+ begin
678
+ result = db.execute(sql, param_list)
679
+ db.commit rescue nil
680
+ { "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
681
+ rescue => e
682
+ { "error" => db.get_error || e.message }
683
+ end
657
684
  }, "Execute arbitrary SQL (INSERT/UPDATE/DELETE/DDL)")
658
685
 
659
686
  server.register_tool("database_tables", lambda {
@@ -13,8 +13,44 @@ end
13
13
  require "base64"
14
14
  require "securerandom"
15
15
  require "time"
16
+ require "socket"
17
+ require "timeout"
18
+ begin
19
+ require "openssl"
20
+ rescue LoadError
21
+ # openssl is part of stdlib; absence only affects TLS error classification
22
+ end
16
23
 
17
24
  module Tina4
25
+ # Raised on a messenger failure (base class).
26
+ class MessengerError < StandardError; end
27
+
28
+ # Raised when an IMAP read fails to connect, authenticate, or speak the
29
+ # protocol. Distinct from a successful fetch that simply has no messages —
30
+ # that still returns an empty result ([]/nil/0/{}), NOT an error.
31
+ #
32
+ # Subclasses MessengerError so existing `rescue Tina4::MessengerError`
33
+ # handlers still catch it.
34
+ class MessengerConnectionError < MessengerError; end
35
+
36
+ # Errors that mean "we could not talk to the mail server", as opposed to
37
+ # "we talked fine and the mailbox is empty". These must fail loud — LOG and
38
+ # RAISE — never be silently swallowed into an empty result. Mirrors the
39
+ # Python master's _IMAP_CONNECTION_ERRORS tuple.
40
+ #
41
+ # Built lazily so a missing net/imap gem (LoadError above) doesn't break
42
+ # loading this file.
43
+ IMAP_CONNECTION_ERRORS = [
44
+ SocketError, # DNS / host resolution failures
45
+ IOError, # closed/broken stream, EOF mid-conversation
46
+ SystemCallError, # Errno::ECONNREFUSED / ECONNRESET / ETIMEDOUT etc.
47
+ Timeout::Error, # connect/read timeout (Net::OpenTimeout descends from this)
48
+ MessengerError # our own protocol-failure signal (re-raised as-is)
49
+ ].tap do |errors|
50
+ errors << Net::IMAP::Error if defined?(Net::IMAP::Error)
51
+ errors << OpenSSL::SSL::SSLError if defined?(OpenSSL::SSL::SSLError)
52
+ end.freeze
53
+
18
54
  # Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
19
55
  #
20
56
  # Unified .env-driven configuration with constructor override.
@@ -120,9 +156,14 @@ module Tina4
120
156
 
121
157
  # ── IMAP operations ──────────────────────────────────────────────────
122
158
 
123
- # List messages in a folder
159
+ # List messages in a folder.
160
+ #
161
+ # Raises Tina4::MessengerConnectionError on a connection/auth/protocol
162
+ # failure (FAILS LOUD — never returns [] to hide it). A successful fetch
163
+ # from an empty folder returns [] (that is NOT an error).
124
164
  def inbox(folder: "INBOX", limit: 20, offset: 0)
125
- imap_connect do |imap|
165
+ imap = imap_open("inbox")
166
+ begin
126
167
  imap.select(folder)
127
168
  uids = imap.uid_search(["ALL"])
128
169
  uids = uids.reverse # newest first
@@ -131,15 +172,20 @@ module Tina4
131
172
 
132
173
  envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
133
174
  (envelopes || []).map { |msg| parse_envelope(msg) }
175
+ rescue *IMAP_CONNECTION_ERRORS => e
176
+ raise imap_fail("inbox", e)
177
+ ensure
178
+ imap_cleanup(imap)
134
179
  end
135
- rescue => e
136
- Tina4::Log.error("IMAP inbox failed: #{e.message}")
137
- []
138
180
  end
139
181
 
140
- # Read a single message by UID
182
+ # Read a single message by UID.
183
+ #
184
+ # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
185
+ # A successful fetch for a non-existent UID returns nil (that is NOT an error).
141
186
  def read(uid, folder: "INBOX", mark_read: true)
142
- imap_connect do |imap|
187
+ imap = imap_open("read")
188
+ begin
143
189
  imap.select(folder)
144
190
  data = imap.uid_fetch(uid, ["ENVELOPE", "FLAGS", "BODY[]", "RFC822.SIZE"])
145
191
  return nil if data.nil? || data.empty?
@@ -150,28 +196,38 @@ module Tina4
150
196
 
151
197
  msg = data.first
152
198
  parse_full_message(msg)
199
+ rescue *IMAP_CONNECTION_ERRORS => e
200
+ raise imap_fail("read", e)
201
+ ensure
202
+ imap_cleanup(imap)
153
203
  end
154
- rescue => e
155
- Tina4::Log.error("IMAP read failed: #{e.message}")
156
- nil
157
204
  end
158
205
 
159
- # Count unread messages
206
+ # Count unread messages.
207
+ #
208
+ # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
209
+ # A successful query with no unseen messages returns 0 (NOT an error).
160
210
  def unread(folder: "INBOX")
161
- imap_connect do |imap|
211
+ imap = imap_open("unread")
212
+ begin
162
213
  imap.select(folder)
163
214
  uids = imap.uid_search(["UNSEEN"])
164
215
  uids.length
216
+ rescue *IMAP_CONNECTION_ERRORS => e
217
+ raise imap_fail("unread", e)
218
+ ensure
219
+ imap_cleanup(imap)
165
220
  end
166
- rescue => e
167
- Tina4::Log.error("IMAP unread count failed: #{e.message}")
168
- 0
169
221
  end
170
222
 
171
- # Search messages with filters
223
+ # Search messages with filters.
224
+ #
225
+ # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
226
+ # A successful search with no matches returns [] (NOT an error).
172
227
  def search(folder: "INBOX", subject: nil, sender: nil, since: nil,
173
228
  before: nil, unseen_only: false, limit: 20)
174
- imap_connect do |imap|
229
+ imap = imap_open("search")
230
+ begin
175
231
  imap.select(folder)
176
232
  criteria = build_search_criteria(
177
233
  subject: subject, sender: sender, since: since,
@@ -184,21 +240,26 @@ module Tina4
184
240
 
185
241
  envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
186
242
  (envelopes || []).map { |msg| parse_envelope(msg) }
243
+ rescue *IMAP_CONNECTION_ERRORS => e
244
+ raise imap_fail("search", e)
245
+ ensure
246
+ imap_cleanup(imap)
187
247
  end
188
- rescue => e
189
- Tina4::Log.error("IMAP search failed: #{e.message}")
190
- []
191
248
  end
192
249
 
193
- # List all IMAP folders
250
+ # List all IMAP folders.
251
+ #
252
+ # Raises Tina4::MessengerConnectionError on a connection/protocol failure.
194
253
  def folders
195
- imap_connect do |imap|
254
+ imap = imap_open("folders")
255
+ begin
196
256
  boxes = imap.list("", "*")
197
257
  (boxes || []).map(&:name)
258
+ rescue *IMAP_CONNECTION_ERRORS => e
259
+ raise imap_fail("folders", e)
260
+ ensure
261
+ imap_cleanup(imap)
198
262
  end
199
- rescue => e
200
- Tina4::Log.error("IMAP folders failed: #{e.message}")
201
- []
202
263
  end
203
264
 
204
265
  # Mark a message as read (set \Seen flag).
@@ -393,6 +454,50 @@ module Tina4
393
454
 
394
455
  # ── IMAP helpers ─────────────────────────────────────────────────────
395
456
 
457
+ # Open and authenticate an IMAP connection. On a connection/auth/protocol
458
+ # failure this FAILS LOUD — logs and raises MessengerConnectionError —
459
+ # rather than swallowing the error into an empty result.
460
+ def imap_open(method)
461
+ imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
462
+ imap.login(@username, @password)
463
+ imap
464
+ rescue *IMAP_CONNECTION_ERRORS => e
465
+ raise imap_fail(method, e)
466
+ end
467
+
468
+ # Best-effort teardown of an IMAP connection. Never raises.
469
+ def imap_cleanup(imap)
470
+ return if imap.nil?
471
+
472
+ begin
473
+ imap.logout
474
+ rescue StandardError
475
+ # ignore — already closing
476
+ end
477
+ begin
478
+ imap.disconnect
479
+ rescue StandardError
480
+ # ignore — already closed
481
+ end
482
+ end
483
+
484
+ # Log an IMAP connection/protocol failure and return the error to raise.
485
+ # A genuinely empty mailbox is NOT an error and never reaches here.
486
+ # Re-raises a MessengerError as-is; otherwise wraps in
487
+ # MessengerConnectionError. Callers do `raise imap_fail(name, exc)`.
488
+ def imap_fail(method, exc)
489
+ Tina4::Log.error(
490
+ "Messenger IMAP #{method}() failed: #{exc.class}: #{exc.message}"
491
+ )
492
+ return exc if exc.is_a?(MessengerError)
493
+
494
+ MessengerConnectionError.new("IMAP #{method} failed: #{exc.message}")
495
+ end
496
+
497
+ # Connect, yield, then tear down — used by the mutators / connectivity test
498
+ # that keep their own result-style error handling (mark_read,
499
+ # test_imap_connection). Read methods use imap_open + ensure directly so
500
+ # they can fail loud.
396
501
  def imap_connect(&block)
397
502
  imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
398
503
  imap.login(@username, @password)