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.
@@ -395,15 +395,34 @@ module Tina4
395
395
  denial = dev_mutation_denial(env)
396
396
  return denial if denial
397
397
 
398
- # Dynamic-path routes can't live in the case-tuple below — match
399
- # them up front. /__dev/api/threads/{id}[/messages] is forwarded
400
- # verbatim to the Rust agent's /threads/{id}[/messages] surface
401
- # (mirrors Python's `_api_threads_sub`).
402
- if path.start_with?("/__dev/api/threads/")
403
- suffix = path[("/__dev/api".length)..] # leaves "/threads/{id}[/messages]"
404
- return threads_sub_proxy(env, method, suffix)
405
- end
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
- body = read_json_body(env) || {}
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/messages/search"]
563
- keyword = query_param(env, "q") || query_param(env, "keyword") || ""
564
- all_messages = message_log.get
565
- filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
566
- json_response({ messages: filtered, count: filtered.size, keyword: keyword })
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
- body = read_json_body(env)
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
- # No fallback (ADR-0002). A missing or stale CLI is a 503 naming the
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
- file_path = (query_param(env, "path") || "").to_s
657
- begin
658
- json_response(Tina4::Metrics.file_detail(file_path))
659
- rescue Tina4::MetricsEngineError => e
660
- # A bad path is the caller's mistake (404); anything else is the
661
- # engine being unavailable (503).
662
- bad_path = e.message.include?("no such file") ||
663
- e.message.include?("not a file") ||
664
- e.message.include?("needs a path")
665
- json_response({ "error" => e.message }, bad_path ? 404 : 503)
666
- end
667
- when ["GET", "/__dev/api/thoughts"]
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
- execute_proxy(body)
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
- when ["GET", "/__dev/api/git/status"]
709
- json_response(git_status_payload)
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
- begin
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
- private
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)