tina4ruby 3.11.17 → 3.11.19

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: c5bc93541bae10ff3eeaf0ab13618b899d3ea3ace7ae346c7e6c0fc77bf54cfb
4
- data.tar.gz: 71bbbf40e7411a7955d675f3a479dddea6f93c17b7b8cc4b91459d738cefc70d
3
+ metadata.gz: 913dfbf6820b99da3ff8f0b5a845fb23239736ccc1db30eb958d664fba6abe9f
4
+ data.tar.gz: c7c39ce01253b8dc60edc30550b004914ac9c4742ee2efabfdedab44dfe984f7
5
5
  SHA512:
6
- metadata.gz: 3b67f60cfd3f12f05d711151892963c935c5297dfd7533649e216cbff8ae1c9a1b383172191524594db2a1ef8a3bd529d58a3913fda910162d42b9e925aaf52a
7
- data.tar.gz: d32f9309802ab0a5051eb330e39df3e43ec7612eb0b03715390871ee1c6413858c5e0a065dd486b1450578e5b3f93fd0a78e97feb6ff1d3e20b9591d587a0cb9
6
+ metadata.gz: c926a2b39b248a45112e3f323174049ccdb0955c07ce3491a9346c837776cca37707ce54ecca21320ad71599b8d537a78eedadf4e675cd8c2b71dcfa6415ca39
7
+ data.tar.gz: caf6b606d28154e7ed8ded6e2519cc4a45f10a9034644f15f82625eace4e2747dbc27d845dadf0f0881a1c180c38a465428976a7be6d7b1c52b4239f637fd793
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # Periodic background task registry.
5
+ #
6
+ # Matches Python's `tina4_python.core.server.background(fn, interval)` and
7
+ # PHP's `$app->background($callback, $interval)` — a callback that runs
8
+ # periodically alongside the server lifecycle.
9
+ #
10
+ # Ruby has no asyncio event loop, so each task runs in its own thread.
11
+ # The GIL keeps it cooperative-enough for the periodic work this is meant
12
+ # for (queue draining, health checks, simulators). Errors in the callback
13
+ # are caught and logged so they don't kill the thread.
14
+ module Background
15
+ class << self
16
+ # Register a periodic callback.
17
+ #
18
+ # @param callback [#call, nil] Object responding to `call` with no args.
19
+ # @param interval [Float] Seconds between invocations (default 1.0).
20
+ # @param block [Proc] Optional block (used if callback is nil).
21
+ # @return [Hash] The registered task descriptor.
22
+ def register(callback = nil, interval: 1.0, &block)
23
+ cb = callback || block
24
+ raise ArgumentError, "background requires a callback or block" if cb.nil?
25
+ raise ArgumentError, "callback must respond to :call" unless cb.respond_to?(:call)
26
+
27
+ task = { callback: cb, interval: interval.to_f, thread: nil, running: false }
28
+ mutex.synchronize { tasks << task }
29
+ start_task(task)
30
+ task
31
+ end
32
+
33
+ # All registered task descriptors. Tests use this for introspection.
34
+ def tasks
35
+ @tasks ||= []
36
+ end
37
+
38
+ # Stop and join every running task. Called on graceful shutdown.
39
+ def stop_all(timeout: 2.0)
40
+ snapshot = mutex.synchronize { tasks.dup }
41
+ snapshot.each { |task| stop_task(task, timeout: timeout) }
42
+ mutex.synchronize { tasks.clear }
43
+ end
44
+
45
+ # Stop a single task. Used by tests that register, fire, then stop.
46
+ def stop_task(task, timeout: 2.0)
47
+ task[:running] = false
48
+ thread = task[:thread]
49
+ return unless thread
50
+
51
+ thread.join(timeout) || thread.kill
52
+ task[:thread] = nil
53
+ end
54
+
55
+ private
56
+
57
+ def mutex
58
+ @mutex ||= Mutex.new
59
+ end
60
+
61
+ def start_task(task)
62
+ task[:running] = true
63
+ task[:thread] = Thread.new do
64
+ while task[:running]
65
+ sleep task[:interval]
66
+ break unless task[:running]
67
+
68
+ begin
69
+ task[:callback].call
70
+ rescue => e
71
+ # Never let a callback error kill the thread — next interval still fires.
72
+ if defined?(Tina4::Log) && Tina4::Log.respond_to?(:error)
73
+ Tina4::Log.error("background task error: #{e.class}: #{e.message}")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -316,9 +316,55 @@ module Tina4
316
316
  Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
317
317
  end
318
318
 
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
+
319
364
  # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
320
365
  def handle_request(env)
321
366
  return nil unless enabled?
367
+ auto_discover_mcp!
322
368
 
323
369
  path = env["PATH_INFO"] || "/"
324
370
  method = env["REQUEST_METHOD"]
@@ -548,6 +594,16 @@ module Tina4
548
594
  when ["POST", "/__dev/api/scaffold/run"]
549
595
  body = read_json_body(env) || {}
550
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)
551
607
  when ["GET", "/__dev/api/graphql/schema"]
552
608
  begin
553
609
  gql = Tina4::GraphQL.new
@@ -1286,6 +1342,56 @@ module Tina4
1286
1342
  { ok: false, error: "unknown kind: #{kind}" }
1287
1343
  end
1288
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
1289
1395
  end
1290
1396
  end
1291
1397
  end