tina4ruby 3.13.131 → 3.13.133
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/CHANGELOG.md +90 -0
- data/lib/tina4/dev_admin.rb +255 -363
- data/lib/tina4/frond.rb +37 -75
- data/lib/tina4/model_collection.rb +87 -0
- data/lib/tina4/orm.rb +39 -20
- data/lib/tina4/public/js/tina4-dev-admin.min.js +148 -512
- data/lib/tina4/rack_app.rb +4 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +120 -138
- data/lib/tina4.rb +1 -0
- metadata +3 -2
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -395,15 +395,34 @@ module Tina4
|
|
|
395
395
|
denial = dev_mutation_denial(env)
|
|
396
396
|
return denial if denial
|
|
397
397
|
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
#
|
|
401
|
-
#
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
398
|
+
# Exact [method, path] dispatch, split by concern. Every route is an
|
|
399
|
+
# exact pair (no overlaps), so the groups form a partition: the first
|
|
400
|
+
# group that matches answers, and a group returns nil when it holds no
|
|
401
|
+
# matching route. Behaviour is identical to the former single 138-branch
|
|
402
|
+
# case — the only handler that legitimately returns nil is an MCP route
|
|
403
|
+
# with the capability disabled (with_mcp_gate), for which the original
|
|
404
|
+
# case also fell through to nil (RackApp then 404s it).
|
|
405
|
+
dispatch_dashboard(method, path, env) ||
|
|
406
|
+
dispatch_inspectors(method, path, env) ||
|
|
407
|
+
dispatch_mailbox(method, path, env) ||
|
|
408
|
+
dispatch_queue(method, path, env) ||
|
|
409
|
+
dispatch_data(method, path, env) ||
|
|
410
|
+
dispatch_config(method, path, env) ||
|
|
411
|
+
dispatch_files(method, path, env) ||
|
|
412
|
+
dispatch_mcp(method, path, env)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
private
|
|
417
|
+
|
|
418
|
+
# ── /__dev request dispatch (split from a single 138-CC case) ──────
|
|
419
|
+
# Each dispatch_* owns one concern's exact [method, path] routes and
|
|
420
|
+
# returns a Rack triple, or nil when it holds no matching route. Branch
|
|
421
|
+
# bodies are the former case's, moved verbatim.
|
|
406
422
|
|
|
423
|
+
# Dashboard shell, injected-toolbar assets, the reload trigger and the
|
|
424
|
+
# core status / routes / system panels.
|
|
425
|
+
def dispatch_dashboard(method, path, env)
|
|
407
426
|
case [method, path]
|
|
408
427
|
when ["GET", "/__dev"], ["GET", "/__dev/"]
|
|
409
428
|
serve_dashboard
|
|
@@ -422,54 +441,24 @@ module Tina4
|
|
|
422
441
|
when ["GET", "/__dev/api/mtime"]
|
|
423
442
|
json_response({ mtime: @reload_mtime || 0, file: @reload_file || "" })
|
|
424
443
|
when ["POST", "/__dev/api/reload"]
|
|
425
|
-
|
|
426
|
-
@reload_mtime = Time.now.to_i
|
|
427
|
-
@reload_file = body["file"] || ""
|
|
428
|
-
reload_type = body["type"] || "reload"
|
|
429
|
-
Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
|
|
430
|
-
# Re-discover so files dropped into src/routes/ register without
|
|
431
|
-
# a server restart. Idempotent — already-loaded files are skipped,
|
|
432
|
-
# changed files are re-loaded (mtime-tracked).
|
|
433
|
-
begin
|
|
434
|
-
Tina4::Router.rescan_routes!
|
|
435
|
-
rescue StandardError => e
|
|
436
|
-
Tina4::Log.error("Re-discover on reload failed: #{e.message}")
|
|
437
|
-
end
|
|
438
|
-
# Keep the code Context index LIVE on the same reload trigger: reindex
|
|
439
|
-
# just the changed file (UPSERT) so the dev-MCP code_search reflects
|
|
440
|
-
# the edit immediately. Only touches an already-built index
|
|
441
|
-
# (existing_context never creates one); guarded so a context failure
|
|
442
|
-
# never breaks the reload.
|
|
443
|
-
begin
|
|
444
|
-
unless @reload_file.to_s.empty?
|
|
445
|
-
ctx = Tina4::Context.existing_context
|
|
446
|
-
ctx&.reindex_file(@reload_file)
|
|
447
|
-
end
|
|
448
|
-
rescue StandardError => e
|
|
449
|
-
Tina4::Log.error("Context reindex on reload failed: #{e.message}")
|
|
450
|
-
end
|
|
451
|
-
# WebSocket-primary reload: push an instant message to every browser
|
|
452
|
-
# connected on /__dev_reload. The toolbar client (and the dev-admin
|
|
453
|
-
# dashboard) act on this immediately — the mtime poll above is only a
|
|
454
|
-
# fallback for when the socket is down. CSS changes swap stylesheets;
|
|
455
|
-
# everything else triggers a full page reload. We normalise the wire
|
|
456
|
-
# `type` to "css"/"reload" (the clients only react to css/reload/change)
|
|
457
|
-
# but still echo the caller's original type in the HTTP response.
|
|
458
|
-
# Wrapped so a broadcast failure — or zero connected clients — never
|
|
459
|
-
# 500s the reload endpoint.
|
|
460
|
-
begin
|
|
461
|
-
ws_type = reload_type == "css" ? "css" : "reload"
|
|
462
|
-
Tina4::DevReload.broadcast(
|
|
463
|
-
JSON.generate({ type: ws_type, file: @reload_file, mtime: @reload_mtime })
|
|
464
|
-
)
|
|
465
|
-
rescue StandardError => e
|
|
466
|
-
Tina4::Log.error("Dev-reload WebSocket broadcast failed: #{e.message}")
|
|
467
|
-
end
|
|
468
|
-
json_response({ ok: true, type: reload_type })
|
|
444
|
+
handle_reload(env)
|
|
469
445
|
when ["GET", "/__dev/api/status"]
|
|
470
446
|
json_response(status_payload)
|
|
471
447
|
when ["GET", "/__dev/api/routes"]
|
|
472
448
|
json_response(routes_payload)
|
|
449
|
+
when ["GET", "/__dev/api/system"]
|
|
450
|
+
json_response(system_payload)
|
|
451
|
+
when ["GET", "/__dev/api/version-check"]
|
|
452
|
+
json_response(version_check_payload)
|
|
453
|
+
when ["GET", "/__dev/api/git/status"]
|
|
454
|
+
json_response(git_status_payload)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Observability panels: message log, request inspector and live
|
|
459
|
+
# WebSocket connections. (Mailbox + error tracker live in dispatch_mailbox.)
|
|
460
|
+
def dispatch_inspectors(method, path, env)
|
|
461
|
+
case [method, path]
|
|
473
462
|
when ["GET", "/__dev/api/messages"]
|
|
474
463
|
category = query_param(env, "category")
|
|
475
464
|
messages = message_log.get(category: category)
|
|
@@ -480,68 +469,31 @@ module Tina4
|
|
|
480
469
|
category = body["category"] if body
|
|
481
470
|
message_log.clear(category: category)
|
|
482
471
|
json_response({ cleared: true })
|
|
472
|
+
when ["GET", "/__dev/api/messages/search"]
|
|
473
|
+
keyword = query_param(env, "q") || query_param(env, "keyword") || ""
|
|
474
|
+
all_messages = message_log.get
|
|
475
|
+
filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
|
|
476
|
+
json_response({ messages: filtered, count: filtered.size, keyword: keyword })
|
|
483
477
|
when ["GET", "/__dev/api/requests"]
|
|
484
478
|
limit = (query_param(env, "limit") || 50).to_i
|
|
485
479
|
json_response({ requests: request_inspector.get(limit: limit), stats: request_inspector.stats })
|
|
486
480
|
when ["POST", "/__dev/api/requests/clear"]
|
|
487
481
|
request_inspector.clear
|
|
488
482
|
json_response({ cleared: true })
|
|
489
|
-
when ["GET", "/__dev/api/system"]
|
|
490
|
-
json_response(system_payload)
|
|
491
|
-
when ["GET", "/__dev/api/queue/topics"]
|
|
492
|
-
json_response({ topics: queue_topics })
|
|
493
|
-
when ["GET", "/__dev/api/queue/dead-letters"]
|
|
494
|
-
topic = query_param(env, "topic") || "default"
|
|
495
|
-
jobs = begin
|
|
496
|
-
queue_dead_letters(topic)
|
|
497
|
-
rescue StandardError
|
|
498
|
-
[]
|
|
499
|
-
end
|
|
500
|
-
json_response({ jobs: jobs, count: jobs.size, topic: topic })
|
|
501
|
-
when ["GET", "/__dev/api/queue"]
|
|
502
|
-
topic = query_param(env, "topic") || "default"
|
|
503
|
-
stats = { pending: 0, completed: 0, failed: 0, reserved: 0 }
|
|
504
|
-
jobs = []
|
|
505
|
-
begin
|
|
506
|
-
if defined?(Tina4::Queue)
|
|
507
|
-
queue = Tina4::Queue.new(backend: :file, topic: topic)
|
|
508
|
-
# Queue#size is keyword-only (def size(status:)). Calling it
|
|
509
|
-
# positionally raises ArgumentError, which the rescue below
|
|
510
|
-
# swallows — silently zeroing every stat. Use keyword form.
|
|
511
|
-
stats = {
|
|
512
|
-
pending: queue.size(status: "pending"),
|
|
513
|
-
completed: queue.size(status: "completed"),
|
|
514
|
-
failed: queue.size(status: "failed"),
|
|
515
|
-
reserved: queue.size(status: "reserved"),
|
|
516
|
-
}
|
|
517
|
-
jobs = queue_jobs(topic, query_param(env, "status"))
|
|
518
|
-
end
|
|
519
|
-
rescue StandardError
|
|
520
|
-
# fall through to empty stats
|
|
521
|
-
end
|
|
522
|
-
json_response({ jobs: jobs, stats: stats })
|
|
523
|
-
when ["GET", "/__dev/api/mailbox"]
|
|
524
|
-
messages = mailbox.inbox
|
|
525
|
-
json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
|
|
526
|
-
when ["GET", "/__dev/api/broken"]
|
|
527
|
-
errors = error_tracker.get(include_resolved: true)
|
|
528
|
-
h = error_tracker.health
|
|
529
|
-
json_response({ errors: errors, count: errors.size, health: h })
|
|
530
|
-
when ["POST", "/__dev/api/broken/resolve"]
|
|
531
|
-
body = read_json_body(env)
|
|
532
|
-
id = body && body["id"]
|
|
533
|
-
resolved = id ? error_tracker.resolve(id) : false
|
|
534
|
-
json_response({ resolved: resolved, id: id })
|
|
535
|
-
when ["POST", "/__dev/api/broken/clear"]
|
|
536
|
-
# "Clear All" button — flush every tracked error, not only the
|
|
537
|
-
# ones individually marked resolved. Matches PHP/Python.
|
|
538
|
-
error_tracker.clear_all
|
|
539
|
-
json_response({ cleared: true })
|
|
540
483
|
when ["GET", "/__dev/api/websockets"]
|
|
541
484
|
json_response(websockets_payload)
|
|
542
485
|
when ["POST", "/__dev/api/websockets/disconnect"]
|
|
543
486
|
body = read_json_body(env) || {}
|
|
544
487
|
json_response(websockets_disconnect(body))
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Dev mailbox (email capture) and the error tracker panel.
|
|
492
|
+
def dispatch_mailbox(method, path, env)
|
|
493
|
+
case [method, path]
|
|
494
|
+
when ["GET", "/__dev/api/mailbox"]
|
|
495
|
+
messages = mailbox.inbox
|
|
496
|
+
json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
|
|
545
497
|
when ["GET", "/__dev/api/mailbox/read"]
|
|
546
498
|
message_id = query_param(env, "id")
|
|
547
499
|
message = mailbox.read(message_id)
|
|
@@ -559,11 +511,39 @@ module Tina4
|
|
|
559
511
|
when ["POST", "/__dev/api/mailbox/clear"]
|
|
560
512
|
mailbox.clear
|
|
561
513
|
json_response({ cleared: true })
|
|
562
|
-
when ["GET", "/__dev/api/
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
514
|
+
when ["GET", "/__dev/api/broken"]
|
|
515
|
+
errors = error_tracker.get(include_resolved: true)
|
|
516
|
+
h = error_tracker.health
|
|
517
|
+
json_response({ errors: errors, count: errors.size, health: h })
|
|
518
|
+
when ["POST", "/__dev/api/broken/resolve"]
|
|
519
|
+
body = read_json_body(env)
|
|
520
|
+
id = body && body["id"]
|
|
521
|
+
resolved = id ? error_tracker.resolve(id) : false
|
|
522
|
+
json_response({ resolved: resolved, id: id })
|
|
523
|
+
when ["POST", "/__dev/api/broken/clear"]
|
|
524
|
+
# "Clear All" button — flush every tracked error, not only the
|
|
525
|
+
# ones individually marked resolved. Matches PHP/Python.
|
|
526
|
+
error_tracker.clear_all
|
|
527
|
+
json_response({ cleared: true })
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Queue panel: topic list, dead letters, per-topic overview and the
|
|
532
|
+
# retry / purge / replay mutations.
|
|
533
|
+
def dispatch_queue(method, path, env)
|
|
534
|
+
case [method, path]
|
|
535
|
+
when ["GET", "/__dev/api/queue/topics"]
|
|
536
|
+
json_response({ topics: queue_topics })
|
|
537
|
+
when ["GET", "/__dev/api/queue/dead-letters"]
|
|
538
|
+
topic = query_param(env, "topic") || "default"
|
|
539
|
+
jobs = begin
|
|
540
|
+
queue_dead_letters(topic)
|
|
541
|
+
rescue StandardError
|
|
542
|
+
[]
|
|
543
|
+
end
|
|
544
|
+
json_response({ jobs: jobs, count: jobs.size, topic: topic })
|
|
545
|
+
when ["GET", "/__dev/api/queue"]
|
|
546
|
+
queue_overview(env)
|
|
567
547
|
when ["POST", "/__dev/api/queue/retry"]
|
|
568
548
|
body = read_json_body(env) || {}
|
|
569
549
|
json_response(queue_retry(body))
|
|
@@ -573,18 +553,18 @@ module Tina4
|
|
|
573
553
|
when ["POST", "/__dev/api/queue/replay"]
|
|
574
554
|
body = read_json_body(env) || {}
|
|
575
555
|
json_response(queue_replay(body))
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Data + tooling: table browser, seeding, the run-chips (migrate / test /
|
|
560
|
+
# seed), the SQL console, table list, the gallery and the metrics panels.
|
|
561
|
+
def dispatch_data(method, path, env)
|
|
562
|
+
case [method, path]
|
|
576
563
|
when ["GET", "/__dev/api/table"]
|
|
577
564
|
table_name = query_param(env, "name")
|
|
578
565
|
json_response(table_detail_payload(table_name))
|
|
579
566
|
when ["POST", "/__dev/api/seed"]
|
|
580
|
-
|
|
581
|
-
table_name = (body && body["table"]) || ""
|
|
582
|
-
count = (body && body["count"]) || 10
|
|
583
|
-
seed = body && body["seed"]
|
|
584
|
-
seed = (Integer(seed) rescue nil) unless seed.nil?
|
|
585
|
-
clear = body && (body["clear"] == true || body["clear"].to_s == "true")
|
|
586
|
-
strict = body && (body["strict"] == true || body["strict"].to_s == "true")
|
|
587
|
-
json_response(seed_table_data(table_name, count.to_i, seed: seed, clear: clear, strict: strict))
|
|
567
|
+
handle_seed(env)
|
|
588
568
|
when ["POST", "/__dev/api/tool"]
|
|
589
569
|
body = read_json_body(env)
|
|
590
570
|
tool = (body && body["tool"]) || ""
|
|
@@ -599,37 +579,6 @@ module Tina4
|
|
|
599
579
|
json_response(run_tests_payload)
|
|
600
580
|
when ["POST", "/__dev/api/seed/run"]
|
|
601
581
|
json_response(run_seeds_payload)
|
|
602
|
-
# Grounding panel — configure the tina4-coder MCP token used for
|
|
603
|
-
# live-docs grounding. Self-contained (.env read/write); no proxy.
|
|
604
|
-
when ["GET", "/__dev/api/grounding/status"]
|
|
605
|
-
json_response(grounding_status_payload)
|
|
606
|
-
when ["POST", "/__dev/api/grounding/token"]
|
|
607
|
-
body = read_json_body(env) || {}
|
|
608
|
-
json_response(grounding_token_save(body))
|
|
609
|
-
when ["POST", "/__dev/api/chat"]
|
|
610
|
-
# Proxy dev-admin chat to the Rust agent's /chat endpoint.
|
|
611
|
-
# The SPA POSTs {message, thread_id?, active_file?, settings?}
|
|
612
|
-
# and expects an SSE stream of `event: status/message/done`
|
|
613
|
-
# chunks. We forward the JSON body verbatim (active_file rides
|
|
614
|
-
# along) and pipe upstream bytes back as they arrive. Mirrors
|
|
615
|
-
# Python's `_api_chat` / `_proxy_to_supervisor` SSE path.
|
|
616
|
-
body = read_json_body(env) || {}
|
|
617
|
-
chat_proxy(body)
|
|
618
|
-
when ["GET", "/__dev/api/threads"]
|
|
619
|
-
# Parity with Python `_api_threads` (GET → list threads).
|
|
620
|
-
json_response(proxy_supervisor("/threads", method: "GET", query: env["QUERY_STRING"]))
|
|
621
|
-
when ["POST", "/__dev/api/threads"]
|
|
622
|
-
# Parity with Python `_api_threads` (POST → create thread).
|
|
623
|
-
body = read_json_body(env) || {}
|
|
624
|
-
json_response(proxy_supervisor("/threads", method: "POST", body: body))
|
|
625
|
-
when ["GET", "/__dev/api/connections"]
|
|
626
|
-
handle_connections_get
|
|
627
|
-
when ["POST", "/__dev/api/connections/test"]
|
|
628
|
-
body = read_json_body(env)
|
|
629
|
-
handle_connections_test(body)
|
|
630
|
-
when ["POST", "/__dev/api/connections/save"]
|
|
631
|
-
body = read_json_body(env)
|
|
632
|
-
handle_connections_save(body)
|
|
633
582
|
when ["POST", "/__dev/api/query"]
|
|
634
583
|
body = read_json_body(env)
|
|
635
584
|
sql = (body && (body["query"] || body["sql"])) || ""
|
|
@@ -642,46 +591,38 @@ module Tina4
|
|
|
642
591
|
body = read_json_body(env)
|
|
643
592
|
name = (body && body["name"]) || ""
|
|
644
593
|
json_response(gallery_deploy(name))
|
|
645
|
-
when ["GET", "/__dev/api/version-check"]
|
|
646
|
-
json_response(version_check_payload)
|
|
647
594
|
when ["GET", "/__dev/api/metrics/full"]
|
|
648
|
-
|
|
649
|
-
# install command, never zeros that read as a healthy codebase.
|
|
650
|
-
begin
|
|
651
|
-
json_response(Tina4::Metrics.full_analysis)
|
|
652
|
-
rescue Tina4::MetricsEngineError => e
|
|
653
|
-
json_response({ "error" => e.message }, 503)
|
|
654
|
-
end
|
|
595
|
+
metrics_full_response
|
|
655
596
|
when ["GET", "/__dev/api/metrics/file"]
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
when ["
|
|
668
|
-
json_response(thoughts_payload)
|
|
669
|
-
when ["POST", "/__dev/api/supervise/create"]
|
|
670
|
-
body = read_json_body(env) || {}
|
|
671
|
-
json_response(proxy_supervisor("/supervise/create", method: "POST", body: body))
|
|
672
|
-
when ["GET", "/__dev/api/supervise/sessions"]
|
|
673
|
-
json_response(proxy_supervisor("/supervise/sessions", method: "GET", query: env["QUERY_STRING"]))
|
|
674
|
-
when ["GET", "/__dev/api/supervise/diff"]
|
|
675
|
-
json_response(proxy_supervisor("/supervise/diff", method: "GET", query: env["QUERY_STRING"]))
|
|
676
|
-
when ["POST", "/__dev/api/supervise/commit"]
|
|
677
|
-
body = read_json_body(env) || {}
|
|
678
|
-
json_response(proxy_supervisor("/supervise/commit", method: "POST", body: body))
|
|
679
|
-
when ["POST", "/__dev/api/supervise/cancel"]
|
|
680
|
-
body = read_json_body(env) || {}
|
|
681
|
-
json_response(proxy_supervisor("/supervise/cancel", method: "POST", body: body))
|
|
682
|
-
when ["POST", "/__dev/api/execute"]
|
|
597
|
+
metrics_file_response(env)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Grounding token panel and the database-connection editor.
|
|
602
|
+
def dispatch_config(method, path, env)
|
|
603
|
+
case [method, path]
|
|
604
|
+
# Grounding panel — configure the tina4-coder MCP token used for
|
|
605
|
+
# live-docs grounding. Self-contained (.env read/write); no proxy.
|
|
606
|
+
when ["GET", "/__dev/api/grounding/status"]
|
|
607
|
+
json_response(grounding_status_payload)
|
|
608
|
+
when ["POST", "/__dev/api/grounding/token"]
|
|
683
609
|
body = read_json_body(env) || {}
|
|
684
|
-
|
|
610
|
+
json_response(grounding_token_save(body))
|
|
611
|
+
when ["GET", "/__dev/api/connections"]
|
|
612
|
+
handle_connections_get
|
|
613
|
+
when ["POST", "/__dev/api/connections/test"]
|
|
614
|
+
body = read_json_body(env)
|
|
615
|
+
handle_connections_test(body)
|
|
616
|
+
when ["POST", "/__dev/api/connections/save"]
|
|
617
|
+
body = read_json_body(env)
|
|
618
|
+
handle_connections_save(body)
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# File browser + editor (read / raw / save / rename / delete) and the
|
|
623
|
+
# dependency search / install surface.
|
|
624
|
+
def dispatch_files(method, path, env)
|
|
625
|
+
case [method, path]
|
|
685
626
|
when ["GET", "/__dev/api/files"]
|
|
686
627
|
json_response(files_list(env))
|
|
687
628
|
when ["GET", "/__dev/api/file"]
|
|
@@ -705,8 +646,13 @@ module Tina4
|
|
|
705
646
|
when ["POST", "/__dev/api/deps/install"]
|
|
706
647
|
body = read_json_body(env) || {}
|
|
707
648
|
json_response(deps_install(body))
|
|
708
|
-
|
|
709
|
-
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# The MCP surfaces (REST shim + JSON-RPC + SSE), scaffolding, and the
|
|
653
|
+
# live-docs (Live API RAG) + GraphQL-schema endpoints.
|
|
654
|
+
def dispatch_mcp(method, path, env)
|
|
655
|
+
case [method, path]
|
|
710
656
|
# All four MCP surfaces (REST shim + JSON-RPC + SSE) go through
|
|
711
657
|
# with_mcp_gate: capability (Tina4.mcp_enabled?) decides whether MCP
|
|
712
658
|
# runs at all (off → route behaves as unmounted, nil → RackApp 404,
|
|
@@ -748,23 +694,133 @@ module Tina4
|
|
|
748
694
|
when ["GET", "/__dev/api/docs/.well-known.json"]
|
|
749
695
|
json_response(docs_well_known_payload)
|
|
750
696
|
when ["GET", "/__dev/api/graphql/schema"]
|
|
751
|
-
|
|
752
|
-
gql = Tina4::GraphQL.new
|
|
753
|
-
# Auto-discover and register all ORM subclasses
|
|
754
|
-
ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
|
|
755
|
-
gql.from_orm(model_class.new)
|
|
756
|
-
end
|
|
757
|
-
json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
|
|
758
|
-
rescue => e
|
|
759
|
-
json_response({ error: e.message }, 400)
|
|
760
|
-
end
|
|
761
|
-
else
|
|
762
|
-
nil
|
|
697
|
+
graphql_schema_response
|
|
763
698
|
end
|
|
764
699
|
end
|
|
765
700
|
|
|
701
|
+
# POST /__dev/api/reload — external reload trigger. Bumps the mtime
|
|
702
|
+
# counter, re-discovers routes, keeps the live docs index fresh, and
|
|
703
|
+
# broadcasts a WebSocket reload to connected browsers.
|
|
704
|
+
def handle_reload(env)
|
|
705
|
+
body = read_json_body(env) || {}
|
|
706
|
+
@reload_mtime = Time.now.to_i
|
|
707
|
+
@reload_file = body["file"] || ""
|
|
708
|
+
reload_type = body["type"] || "reload"
|
|
709
|
+
Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
|
|
710
|
+
# Re-discover so files dropped into src/routes/ register without
|
|
711
|
+
# a server restart. Idempotent — already-loaded files are skipped,
|
|
712
|
+
# changed files are re-loaded (mtime-tracked).
|
|
713
|
+
begin
|
|
714
|
+
Tina4::Router.rescan_routes!
|
|
715
|
+
rescue StandardError => e
|
|
716
|
+
Tina4::Log.error("Re-discover on reload failed: #{e.message}")
|
|
717
|
+
end
|
|
718
|
+
# Keep the code Context index LIVE on the same reload trigger: reindex
|
|
719
|
+
# just the changed file (UPSERT) so the dev-MCP code_search reflects
|
|
720
|
+
# the edit immediately. Only touches an already-built index
|
|
721
|
+
# (existing_context never creates one); guarded so a context failure
|
|
722
|
+
# never breaks the reload.
|
|
723
|
+
begin
|
|
724
|
+
unless @reload_file.to_s.empty?
|
|
725
|
+
ctx = Tina4::Context.existing_context
|
|
726
|
+
ctx&.reindex_file(@reload_file)
|
|
727
|
+
end
|
|
728
|
+
rescue StandardError => e
|
|
729
|
+
Tina4::Log.error("Context reindex on reload failed: #{e.message}")
|
|
730
|
+
end
|
|
731
|
+
# WebSocket-primary reload: push an instant message to every browser
|
|
732
|
+
# connected on /__dev_reload. The toolbar client (and the dev-admin
|
|
733
|
+
# dashboard) act on this immediately — the mtime poll above is only a
|
|
734
|
+
# fallback for when the socket is down. CSS changes swap stylesheets;
|
|
735
|
+
# everything else triggers a full page reload. We normalise the wire
|
|
736
|
+
# `type` to "css"/"reload" (the clients only react to css/reload/change)
|
|
737
|
+
# but still echo the caller's original type in the HTTP response.
|
|
738
|
+
# Wrapped so a broadcast failure — or zero connected clients — never
|
|
739
|
+
# 500s the reload endpoint.
|
|
740
|
+
begin
|
|
741
|
+
ws_type = reload_type == "css" ? "css" : "reload"
|
|
742
|
+
Tina4::DevReload.broadcast(
|
|
743
|
+
JSON.generate({ type: ws_type, file: @reload_file, mtime: @reload_mtime })
|
|
744
|
+
)
|
|
745
|
+
rescue StandardError => e
|
|
746
|
+
Tina4::Log.error("Dev-reload WebSocket broadcast failed: #{e.message}")
|
|
747
|
+
end
|
|
748
|
+
json_response({ ok: true, type: reload_type })
|
|
749
|
+
end
|
|
766
750
|
|
|
767
|
-
|
|
751
|
+
# GET /__dev/api/queue — per-topic stats + job list. Queue#size is
|
|
752
|
+
# keyword-only; the rescue swallows a backend error into empty stats.
|
|
753
|
+
def queue_overview(env)
|
|
754
|
+
topic = query_param(env, "topic") || "default"
|
|
755
|
+
stats = { pending: 0, completed: 0, failed: 0, reserved: 0 }
|
|
756
|
+
jobs = []
|
|
757
|
+
begin
|
|
758
|
+
if defined?(Tina4::Queue)
|
|
759
|
+
queue = Tina4::Queue.new(backend: :file, topic: topic)
|
|
760
|
+
# Queue#size is keyword-only (def size(status:)). Calling it
|
|
761
|
+
# positionally raises ArgumentError, which the rescue below
|
|
762
|
+
# swallows — silently zeroing every stat. Use keyword form.
|
|
763
|
+
stats = {
|
|
764
|
+
pending: queue.size(status: "pending"),
|
|
765
|
+
completed: queue.size(status: "completed"),
|
|
766
|
+
failed: queue.size(status: "failed"),
|
|
767
|
+
reserved: queue.size(status: "reserved"),
|
|
768
|
+
}
|
|
769
|
+
jobs = queue_jobs(topic, query_param(env, "status"))
|
|
770
|
+
end
|
|
771
|
+
rescue StandardError
|
|
772
|
+
# fall through to empty stats
|
|
773
|
+
end
|
|
774
|
+
json_response({ jobs: jobs, stats: stats })
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# POST /__dev/api/seed — seed fake rows into a table via the shared
|
|
778
|
+
# resilient seed_table helper.
|
|
779
|
+
def handle_seed(env)
|
|
780
|
+
body = read_json_body(env)
|
|
781
|
+
table_name = (body && body["table"]) || ""
|
|
782
|
+
count = (body && body["count"]) || 10
|
|
783
|
+
seed = body && body["seed"]
|
|
784
|
+
seed = (Integer(seed) rescue nil) unless seed.nil?
|
|
785
|
+
clear = body && (body["clear"] == true || body["clear"].to_s == "true")
|
|
786
|
+
strict = body && (body["strict"] == true || body["strict"].to_s == "true")
|
|
787
|
+
json_response(seed_table_data(table_name, count.to_i, seed: seed, clear: clear, strict: strict))
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# GET /__dev/api/metrics/full — native metrics (ADR-0002). No fallback:
|
|
791
|
+
# a missing/stale CLI is a 503 naming the install command, never zeros
|
|
792
|
+
# that read as a healthy codebase.
|
|
793
|
+
def metrics_full_response
|
|
794
|
+
json_response(Tina4::Metrics.full_analysis)
|
|
795
|
+
rescue Tina4::MetricsEngineError => e
|
|
796
|
+
json_response({ "error" => e.message }, 503)
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# GET /__dev/api/metrics/file — metrics for one file. A bad path is the
|
|
800
|
+
# caller's mistake (404); anything else is the engine being unavailable
|
|
801
|
+
# (503).
|
|
802
|
+
def metrics_file_response(env)
|
|
803
|
+
file_path = (query_param(env, "path") || "").to_s
|
|
804
|
+
json_response(Tina4::Metrics.file_detail(file_path))
|
|
805
|
+
rescue Tina4::MetricsEngineError => e
|
|
806
|
+
bad_path = e.message.include?("no such file") ||
|
|
807
|
+
e.message.include?("not a file") ||
|
|
808
|
+
e.message.include?("needs a path")
|
|
809
|
+
json_response({ "error" => e.message }, bad_path ? 404 : 503)
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# GET /__dev/api/graphql/schema — introspect every ORM subclass into a
|
|
813
|
+
# GraphQL schema + SDL.
|
|
814
|
+
def graphql_schema_response
|
|
815
|
+
gql = Tina4::GraphQL.new
|
|
816
|
+
# Auto-discover and register all ORM subclasses
|
|
817
|
+
ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
|
|
818
|
+
gql.from_orm(model_class.new)
|
|
819
|
+
end
|
|
820
|
+
json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
|
|
821
|
+
rescue => e
|
|
822
|
+
json_response({ error: e.message }, 400)
|
|
823
|
+
end
|
|
768
824
|
|
|
769
825
|
def query_param(env, key)
|
|
770
826
|
qs = env["QUERY_STRING"] || ""
|
|
@@ -1580,170 +1636,6 @@ module Tina4
|
|
|
1580
1636
|
{ deployed: name, files: copied }
|
|
1581
1637
|
end
|
|
1582
1638
|
|
|
1583
|
-
# ── New dev-admin surface area (parity with Python/PHP) ────
|
|
1584
|
-
|
|
1585
|
-
def supervisor_base
|
|
1586
|
-
base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
|
|
1587
|
-
return base unless base.empty?
|
|
1588
|
-
port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
|
|
1589
|
-
"http://127.0.0.1:#{port}"
|
|
1590
|
-
end
|
|
1591
|
-
|
|
1592
|
-
def thoughts_payload
|
|
1593
|
-
base = supervisor_base
|
|
1594
|
-
begin
|
|
1595
|
-
uri = URI.parse("#{base}/thoughts")
|
|
1596
|
-
req = Net::HTTP::Get.new(uri)
|
|
1597
|
-
resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) { |h| h.request(req) }
|
|
1598
|
-
return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
|
|
1599
|
-
{ thoughts: [], error: "Supervisor returned #{resp.code}" }
|
|
1600
|
-
rescue StandardError => e
|
|
1601
|
-
{ thoughts: [], error: e.message }
|
|
1602
|
-
end
|
|
1603
|
-
end
|
|
1604
|
-
|
|
1605
|
-
def proxy_supervisor(path, method: "GET", body: nil, query: nil)
|
|
1606
|
-
base = supervisor_base
|
|
1607
|
-
url = "#{base}#{path}"
|
|
1608
|
-
url += "?#{query}" if query && !query.empty?
|
|
1609
|
-
begin
|
|
1610
|
-
uri = URI.parse(url)
|
|
1611
|
-
req = case method.upcase
|
|
1612
|
-
when "POST"
|
|
1613
|
-
r = Net::HTTP::Post.new(uri)
|
|
1614
|
-
r["Content-Type"] = "application/json"
|
|
1615
|
-
r.body = JSON.generate(body || {})
|
|
1616
|
-
r
|
|
1617
|
-
when "PATCH"
|
|
1618
|
-
r = Net::HTTP::Patch.new(uri)
|
|
1619
|
-
r["Content-Type"] = "application/json"
|
|
1620
|
-
r.body = JSON.generate(body || {})
|
|
1621
|
-
r
|
|
1622
|
-
when "PUT"
|
|
1623
|
-
r = Net::HTTP::Put.new(uri)
|
|
1624
|
-
r["Content-Type"] = "application/json"
|
|
1625
|
-
r.body = JSON.generate(body || {})
|
|
1626
|
-
r
|
|
1627
|
-
when "DELETE"
|
|
1628
|
-
r = Net::HTTP::Delete.new(uri)
|
|
1629
|
-
r["Content-Type"] = "application/json"
|
|
1630
|
-
r.body = JSON.generate(body) if body
|
|
1631
|
-
r
|
|
1632
|
-
else
|
|
1633
|
-
Net::HTTP::Get.new(uri)
|
|
1634
|
-
end
|
|
1635
|
-
resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 30) { |h| h.request(req) }
|
|
1636
|
-
begin
|
|
1637
|
-
JSON.parse(resp.body)
|
|
1638
|
-
rescue JSON::ParserError
|
|
1639
|
-
{ body: resp.body, status: resp.code.to_i }
|
|
1640
|
-
end
|
|
1641
|
-
rescue StandardError => e
|
|
1642
|
-
{ error: e.message, supervisor: base }
|
|
1643
|
-
end
|
|
1644
|
-
end
|
|
1645
|
-
|
|
1646
|
-
# Proxy /__dev/api/threads/{id}[/messages] through to the Rust
|
|
1647
|
-
# agent. Mirrors Python's `_api_threads_sub`: we already stripped
|
|
1648
|
-
# the dev-admin prefix in handle_request, so `suffix` is the path
|
|
1649
|
-
# the agent expects (e.g. `/threads/abc` or `/threads/abc/messages`).
|
|
1650
|
-
# PATCH /threads/{id} (archive/rename) and GET /threads/{id}/messages
|
|
1651
|
-
# are the two shapes the SPA actually fires.
|
|
1652
|
-
def threads_sub_proxy(env, method, suffix)
|
|
1653
|
-
case method.upcase
|
|
1654
|
-
when "GET"
|
|
1655
|
-
json_response(proxy_supervisor(suffix, method: "GET", query: env["QUERY_STRING"]))
|
|
1656
|
-
when "POST"
|
|
1657
|
-
body = read_json_body(env) || {}
|
|
1658
|
-
json_response(proxy_supervisor(suffix, method: "POST", body: body))
|
|
1659
|
-
when "PATCH"
|
|
1660
|
-
body = read_json_body(env) || {}
|
|
1661
|
-
json_response(proxy_supervisor(suffix, method: "PATCH", body: body))
|
|
1662
|
-
when "PUT"
|
|
1663
|
-
body = read_json_body(env) || {}
|
|
1664
|
-
json_response(proxy_supervisor(suffix, method: "PUT", body: body))
|
|
1665
|
-
when "DELETE"
|
|
1666
|
-
body = read_json_body(env)
|
|
1667
|
-
json_response(proxy_supervisor(suffix, method: "DELETE", body: body))
|
|
1668
|
-
else
|
|
1669
|
-
[405, { "content-type" => "application/json; charset=utf-8" },
|
|
1670
|
-
[JSON.generate({ error: "method not allowed" })]]
|
|
1671
|
-
end
|
|
1672
|
-
end
|
|
1673
|
-
|
|
1674
|
-
# POST /chat — proxy the SPA's chat payload to the Rust agent and
|
|
1675
|
-
# pipe the upstream SSE response back to the browser. Active-file
|
|
1676
|
-
# content (when present in the body) rides along verbatim.
|
|
1677
|
-
#
|
|
1678
|
-
# NOTE on streaming: Tina4 Ruby's Rack app does not currently
|
|
1679
|
-
# expose a chunk-by-chunk streaming API to handlers (see
|
|
1680
|
-
# response.rb — `response.stream(&block)` is not yet wired through
|
|
1681
|
-
# the case-tuple dispatcher used here). We do the next best thing:
|
|
1682
|
-
# use Net::HTTP#request_get with a block so we receive upstream
|
|
1683
|
-
# SSE chunks as they arrive, buffer them, and return the assembled
|
|
1684
|
-
# body with the upstream content-type intact. The SPA's
|
|
1685
|
-
# EventSource reader works either way (it parses `data:` lines
|
|
1686
|
-
# regardless of arrival cadence) — the TODO below tracks
|
|
1687
|
-
# converting this to a true streamed Rack body once dev_admin
|
|
1688
|
-
# routes are migrated off the case-tuple dispatcher.
|
|
1689
|
-
def chat_proxy(body)
|
|
1690
|
-
base = supervisor_base
|
|
1691
|
-
begin
|
|
1692
|
-
uri = URI.parse("#{base}/chat")
|
|
1693
|
-
req = Net::HTTP::Post.new(uri)
|
|
1694
|
-
req["Content-Type"] = "application/json"
|
|
1695
|
-
req["Accept"] = "text/event-stream"
|
|
1696
|
-
req.body = JSON.generate(body || {})
|
|
1697
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
1698
|
-
http.open_timeout = 2
|
|
1699
|
-
# /chat runs the supervisor → planner → coder loop with one or
|
|
1700
|
-
# more LLM round-trips. Matches Python's 600s budget.
|
|
1701
|
-
http.read_timeout = 600
|
|
1702
|
-
chunks = []
|
|
1703
|
-
upstream_status = 200
|
|
1704
|
-
upstream_ct = "text/event-stream"
|
|
1705
|
-
http.request(req) do |resp|
|
|
1706
|
-
upstream_status = resp.code.to_i
|
|
1707
|
-
upstream_ct = resp["content-type"] || upstream_ct
|
|
1708
|
-
# TODO: stream chunks straight into the Rack response once
|
|
1709
|
-
# dev_admin migrates to response.stream(). For now we
|
|
1710
|
-
# buffer — the SPA's SSE reader still parses correctly.
|
|
1711
|
-
resp.read_body { |chunk| chunks << chunk }
|
|
1712
|
-
end
|
|
1713
|
-
[upstream_status, { "content-type" => upstream_ct }, [chunks.join]]
|
|
1714
|
-
rescue StandardError => e
|
|
1715
|
-
body_str = JSON.generate({
|
|
1716
|
-
error: "supervisor unavailable",
|
|
1717
|
-
detail: e.message,
|
|
1718
|
-
hint: "Run `tina4 serve` (starts the agent server) or set TINA4_SUPERVISOR_URL",
|
|
1719
|
-
supervisor: base
|
|
1720
|
-
})
|
|
1721
|
-
[503, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
|
|
1722
|
-
end
|
|
1723
|
-
end
|
|
1724
|
-
|
|
1725
|
-
def execute_proxy(body)
|
|
1726
|
-
# Proxy POST /execute to the supervisor at framework_port + 2000.
|
|
1727
|
-
# Pass through the response stream as-is (SSE or JSON).
|
|
1728
|
-
base = supervisor_base
|
|
1729
|
-
begin
|
|
1730
|
-
uri = URI.parse("#{base}/execute")
|
|
1731
|
-
req = Net::HTTP::Post.new(uri)
|
|
1732
|
-
req["Content-Type"] = "application/json"
|
|
1733
|
-
req["Accept"] = "text/event-stream"
|
|
1734
|
-
req.body = JSON.generate(body || {})
|
|
1735
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
1736
|
-
http.open_timeout = 2
|
|
1737
|
-
http.read_timeout = 300
|
|
1738
|
-
resp = http.request(req)
|
|
1739
|
-
ct = resp["content-type"] || "application/json; charset=utf-8"
|
|
1740
|
-
[resp.code.to_i, { "content-type" => ct }, [resp.body.to_s]]
|
|
1741
|
-
rescue StandardError => e
|
|
1742
|
-
body_str = JSON.generate({ error: e.message, supervisor: base })
|
|
1743
|
-
[502, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
|
|
1744
|
-
end
|
|
1745
|
-
end
|
|
1746
|
-
|
|
1747
1639
|
def safe_project_path(rel_path)
|
|
1748
1640
|
root = File.expand_path(Dir.pwd)
|
|
1749
1641
|
resolved = File.expand_path(rel_path.to_s, root)
|