rubino-agent 0.3.0

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.
Files changed (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. metadata +856 -0
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module API
7
+ module Operations
8
+ module Runs
9
+ # GET /v1/runs/:id/events — Server-Sent Events stream.
10
+ #
11
+ # Replays persisted events (honoring the `Last-Event-ID` header for
12
+ # resume), then polls for new ones at POLL_INTERVAL until the run
13
+ # reaches a terminal status (completed/failed/stopped) or disappears.
14
+ # Puma handles the chunked transfer transparently.
15
+ #
16
+ # @return [[Integer, Hash, Enumerable]] 200 + SSE headers + lazy streamer.
17
+ # @raise [Rubino::NotFoundError] when the run does not exist.
18
+ class EventsOperation
19
+ TERMINAL_STATUSES = %w[completed failed stopped].freeze
20
+ POLL_INTERVAL = 0.25
21
+ # Proxies (nginx, caddy, ALB) close idle connections around 30–60s;
22
+ # 15s leaves margin and also exercises the write path so we notice
23
+ # client disconnects (EPIPE/ECONNRESET) without waiting for a real event.
24
+ HEARTBEAT_INTERVAL = 15.0
25
+ HEARTBEAT_FRAME = ": heartbeat\n\n"
26
+ # Watchdog: if the run is still "running" but no new event has been
27
+ # written for this many seconds, give up and mark it failed. Covers
28
+ # cases the Executor's rescue can't (model in an infinite tool loop,
29
+ # provider stream silently stalled, OS thread killed by a signal we
30
+ # never saw). Generous enough to outlast a slow tool call but well
31
+ # under the SSE consumer's job timeout. Tunable via config so an op can dial
32
+ # it down for short tasks or up for legit long-running computations.
33
+ DEFAULT_IDLE_EVENT_TIMEOUT = 300.0
34
+ # Writes to a closed/aborted socket surface as one of these; we treat
35
+ # them all as "client gone" and stop polling so the thread doesn't
36
+ # leak until the run reaches a terminal status.
37
+ DISCONNECT_ERRORS = [Errno::EPIPE, Errno::ECONNRESET, IOError].freeze
38
+
39
+ def self.call(request)
40
+ new.call(request)
41
+ end
42
+
43
+ # Accepts an alternate run repository and event store for tests.
44
+ # `clock` and `sleeper` are seams so heartbeat/disconnect specs can
45
+ # drive virtual time without sleeping in real wall-clock seconds.
46
+ # `idle_event_timeout` overrides the watchdog window (defaults from
47
+ # config so ops can dial without code changes; nil disables it).
48
+ def initialize(repository: nil, event_store: nil, clock: nil, sleeper: nil, idle_event_timeout: :default)
49
+ @repository = repository || ::Rubino::Run::Repository.new
50
+ @store = event_store || ::Rubino::Run::EventStore.new
51
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
52
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
53
+ @idle_event_timeout = idle_event_timeout == :default ? configured_idle_timeout : idle_event_timeout
54
+ end
55
+
56
+ def call(request)
57
+ id = request.params.fetch("id")
58
+ run = @repository.find(id)
59
+ raise NotFoundError.new("run", id) unless run
60
+
61
+ after_seq = parse_last_event_id(request.header("Last-Event-ID"))
62
+ headers = {
63
+ "content-type" => "text/event-stream",
64
+ "cache-control" => "no-cache",
65
+ "x-accel-buffering" => "no"
66
+ }
67
+ [200, headers, build_stream(id, after_seq)]
68
+ end
69
+
70
+ private
71
+
72
+ def parse_last_event_id(header_value)
73
+ return nil if header_value.nil? || header_value.empty?
74
+
75
+ Integer(header_value, 10)
76
+ rescue ArgumentError
77
+ nil
78
+ end
79
+
80
+ def build_stream(run_id, after_seq)
81
+ store = @store
82
+ repo = @repository
83
+ clock = @clock
84
+ sleeper = @sleeper
85
+ idle_timeout = @idle_event_timeout
86
+ Enumerator.new do |y|
87
+ cursor = after_seq
88
+ now = clock.call
89
+ last_write_at = now
90
+ last_real_event_at = now
91
+ begin
92
+ # Replay persisted events first.
93
+ store.for_run(run_id, after_seq: cursor).each do |event|
94
+ cursor = event[:seq]
95
+ y << format_event(event)
96
+ last_write_at = clock.call
97
+ last_real_event_at = last_write_at
98
+ end
99
+ # Then poll for new events until terminal.
100
+ loop do
101
+ fresh = store.for_run(run_id, after_seq: cursor)
102
+ fresh.each do |event|
103
+ cursor = event[:seq]
104
+ y << format_event(event)
105
+ last_write_at = clock.call
106
+ last_real_event_at = last_write_at
107
+ end
108
+ run = repo.find(run_id)
109
+ break if run.nil? || TERMINAL_STATUSES.include?(run[:status])
110
+
111
+ # A run parked on a human approval/clarification is NOT idle —
112
+ # it is deliberately waiting. Suspend the watchdog while the
113
+ # run's gate has a pending decision and keep the clock fresh so
114
+ # the timer doesn't fire the instant the answer arrives.
115
+ last_real_event_at = clock.call if gate_pending?(run_id)
116
+
117
+ # Watchdog: if the run says "running" but the executor has
118
+ # gone silent for too long, escalate. Marks the row as
119
+ # failed (so the next /v1/runs query reflects truth) and
120
+ # appends a synthetic run.failed event so SSE consumers
121
+ # observe a proper terminal frame and can stop polling.
122
+ if idle_timeout && (clock.call - last_real_event_at) >= idle_timeout
123
+
124
+ handle_idle_timeout(repo, store, run_id, idle_timeout)
125
+ fresh = store.for_run(run_id, after_seq: cursor)
126
+ fresh.each do |event|
127
+ cursor = event[:seq]
128
+ y << format_event(event)
129
+ end
130
+ break
131
+ end
132
+
133
+ if clock.call - last_write_at >= HEARTBEAT_INTERVAL
134
+ y << HEARTBEAT_FRAME
135
+ last_write_at = clock.call
136
+ end
137
+
138
+ sleeper.call(POLL_INTERVAL)
139
+ end
140
+ rescue *DISCONNECT_ERRORS
141
+ # Client (or proxy) closed the connection. Nothing to clean up:
142
+ # falling out of the Enumerator block ends the stream and lets
143
+ # Puma reclaim the thread.
144
+ nil
145
+ end
146
+ end
147
+ end
148
+
149
+ # True when this run is currently blocked on a human approval or
150
+ # clarification. The gate lives in the in-process GateRegistry; a nil
151
+ # gate (run finished, or another worker) is simply "not pending".
152
+ def gate_pending?(run_id)
153
+ gate = ::Rubino::Run::GateRegistry.fetch(run_id)
154
+ gate.respond_to?(:pending?) && gate.pending?
155
+ rescue StandardError
156
+ false
157
+ end
158
+
159
+ def configured_idle_timeout
160
+ cfg = Rubino.configuration if defined?(Rubino) && Rubino.respond_to?(:configuration)
161
+ value = cfg && cfg.respond_to?(:run_idle_event_timeout) ? cfg.run_idle_event_timeout : nil
162
+ value.nil? ? DEFAULT_IDLE_EVENT_TIMEOUT : value
163
+ rescue StandardError
164
+ DEFAULT_IDLE_EVENT_TIMEOUT
165
+ end
166
+
167
+ # When the watchdog fires the run is, by definition, in an
168
+ # inconsistent state — the worker thread is alive long enough that
169
+ # systemd thinks the process is healthy but isn't emitting anymore.
170
+ # We update the DB row first (authoritative) then append the
171
+ # terminal event (best-effort; failure leaves the row consistent).
172
+ def handle_idle_timeout(repo, store, run_id, timeout_seconds)
173
+ error_message = "run idle: no new events for #{timeout_seconds.to_i}s"
174
+ Rubino.logger.warn(event: "run.idle_timeout", run_id: run_id, timeout_s: timeout_seconds)
175
+ repo.mark_failed!(run_id, error: error_message)
176
+ run = repo.find(run_id)
177
+ store.append(
178
+ session_id: run && run[:session_id],
179
+ run_id: run_id,
180
+ type: "run.failed",
181
+ payload: { error: error_message, reason: "idle_timeout" }
182
+ )
183
+ rescue StandardError => e
184
+ Rubino.logger.error(event: "run.idle_timeout_error", run_id: run_id, error: e.class.name,
185
+ message: e.message)
186
+ end
187
+
188
+ def format_event(event)
189
+ "id: #{event[:seq]}\nevent: #{event[:type]}\ndata: #{event[:payload_json]}\n\n"
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Runs
7
+ # POST /v1/runs/:id/stop
8
+ # Cooperative stop: flags the run for cancellation; the executor checks
9
+ # between turns and exits cleanly. Returns 200 immediately — the run may
10
+ # still take a turn to wind down.
11
+ #
12
+ # @raise [Rubino::NotFoundError] when the run does not exist.
13
+ class StopOperation
14
+ def self.call(request)
15
+ new.call(request)
16
+ end
17
+
18
+ # Accepts an alternate repository for tests.
19
+ def initialize(repository: nil)
20
+ @repository = repository || ::Rubino::Run::Repository.new
21
+ end
22
+
23
+ def call(request)
24
+ id = request.params.fetch("id")
25
+ raise NotFoundError.new("run", id) unless @repository.find(id)
26
+
27
+ @repository.request_stop!(id)
28
+ [200, { id: id, status: "stop_requested" }]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Sessions
7
+ # POST /v1/sessions
8
+ # Creates a session row (source="api") and returns its serialized form.
9
+ #
10
+ # @return [[Integer, Hash]] 201 + session payload.
11
+ # @raise [Rubino::ValidationError] when the body fails Schemas::CreateSession.
12
+ class CreateOperation
13
+ def self.call(request)
14
+ new.call(request)
15
+ end
16
+
17
+ # Accepts an alternate repository for tests.
18
+ def initialize(repository: nil)
19
+ @repository = repository || ::Rubino::Session::Repository.new
20
+ end
21
+
22
+ def call(request)
23
+ attrs = request.validate!(Schemas::CreateSession)
24
+ session = @repository.create(
25
+ source: "api",
26
+ title: attrs[:title],
27
+ parent_session_id: attrs[:parent_id]
28
+ )
29
+ [201, serialize(session)]
30
+ end
31
+
32
+ private
33
+
34
+ def serialize(session)
35
+ {
36
+ id: session[:id],
37
+ title: session[:title],
38
+ parent_id: session[:parent_session_id],
39
+ created_at: session[:created_at]
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Sessions
7
+ # DELETE /v1/sessions/:id
8
+ # Cascade-deletes the session and its messages/runs/events.
9
+ #
10
+ # @return [[Integer, Hash]] 204 No Content.
11
+ # @raise [Rubino::NotFoundError] when the session does not exist.
12
+ class DeleteOperation
13
+ def self.call(request)
14
+ new.call(request)
15
+ end
16
+
17
+ # Accepts an alternate repository for tests.
18
+ def initialize(repository: nil)
19
+ @repository = repository || ::Rubino::Session::Repository.new
20
+ end
21
+
22
+ def call(request)
23
+ id = request.params.fetch("id")
24
+ raise NotFoundError.new("session", id) unless @repository.find(id)
25
+
26
+ @repository.destroy!(id)
27
+ Responses.no_content
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Sessions
7
+ # GET /v1/sessions
8
+ # Lists recent sessions. When `?q=` is present, switches to FTS5 mode
9
+ # and returns the sessions whose messages match the query, ordered by
10
+ # the most-recent matching message. Reusing the same route keeps the
11
+ # client surface small — clients only need to learn one endpoint.
12
+ class IndexOperation
13
+ DEFAULT_LIMIT = 20
14
+ MAX_LIMIT = 100
15
+
16
+ def self.call(request)
17
+ new.call(request)
18
+ end
19
+
20
+ # Accepts alternate dependencies for tests.
21
+ def initialize(repository: nil, message_store: nil)
22
+ @repository = repository || ::Rubino::Session::Repository.new
23
+ @message_store = message_store || ::Rubino::Session::Store.new
24
+ end
25
+
26
+ def call(request)
27
+ limit = clamp_limit(request.query["limit"])
28
+ q = request.query["q"].to_s.strip
29
+
30
+ sessions = q.empty? ? list_recent(limit) : search(q, limit)
31
+ [200, { sessions: sessions.map { |s| serialize(s) } }]
32
+ end
33
+
34
+ private
35
+
36
+ def list_recent(limit)
37
+ @repository.list(limit: limit)
38
+ end
39
+
40
+ # Search mode: group FTS5 hits by session, ordered by the latest hit.
41
+ # The store gives us per-message rows; we collapse them down to one
42
+ # entry per session and look up the session row to keep the wire
43
+ # shape identical to list mode.
44
+ def search(q, limit)
45
+ hits = @message_store.search(query: q, limit: MAX_LIMIT)
46
+ ordered_ids = []
47
+ seen = {}
48
+ hits.each do |hit|
49
+ sid = hit[:session_id]
50
+ next if seen[sid]
51
+
52
+ seen[sid] = true
53
+ ordered_ids << sid
54
+ break if ordered_ids.size >= limit
55
+ end
56
+
57
+ ordered_ids.filter_map { |id| @repository.find(id) }
58
+ end
59
+
60
+ def clamp_limit(raw)
61
+ n = raw.to_i
62
+ return DEFAULT_LIMIT if n <= 0
63
+
64
+ [n, MAX_LIMIT].min
65
+ end
66
+
67
+ def serialize(session)
68
+ {
69
+ id: session[:id],
70
+ title: session[:title],
71
+ status: session[:status],
72
+ created_at: session[:created_at],
73
+ updated_at: session[:updated_at],
74
+ message_count: session[:message_count],
75
+ token_count: session[:token_count]
76
+ }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Sessions
7
+ # POST /v1/sessions/:id/retry
8
+ # Deletes the last user message and everything after it, then enqueues a
9
+ # fresh run with the same input. Returns 202 with the new run id.
10
+ #
11
+ # @return [[Integer, Hash]] 202 + { run_id, session_id, status: "running" }.
12
+ # @raise [Rubino::NotFoundError] when the session does not exist.
13
+ # @raise [Rubino::ConflictError] when the session has no user message to retry.
14
+ class RetryOperation
15
+ def self.call(request)
16
+ new.call(request)
17
+ end
18
+
19
+ # Accepts alternate collaborators (session repo, message store, run repo, executor) for tests.
20
+ def initialize(session_repository: nil, message_store: nil, run_repository: nil, executor: nil)
21
+ @session_repo = session_repository || ::Rubino::Session::Repository.new
22
+ @message_store = message_store || ::Rubino::Session::Store.new
23
+ @run_repo = run_repository || ::Rubino::Run::Repository.new
24
+ @executor = executor || ::Rubino::Run::Executor.new
25
+ end
26
+
27
+ def call(request)
28
+ session_id = request.params.fetch("id")
29
+ raise NotFoundError.new("session", session_id) unless @session_repo.find(session_id)
30
+
31
+ last_user = @message_store.last_for_role(session_id, "user")
32
+ raise ConflictError, "no user message to retry" unless last_user
33
+
34
+ @message_store.delete_from_inclusive(session_id, from_id: last_user.id)
35
+
36
+ run = @run_repo.create(session_id: session_id, input_text: last_user.content)
37
+ @executor.start(run)
38
+
39
+ [202, { run_id: run[:id], session_id: session_id, status: "running" }]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module API
7
+ module Operations
8
+ module Sessions
9
+ # GET /v1/sessions/:id
10
+ # Returns the session with its message timeline inlined.
11
+ #
12
+ # @raise [Rubino::NotFoundError] when the session does not exist.
13
+ class ShowOperation
14
+ def self.call(request)
15
+ new.call(request)
16
+ end
17
+
18
+ # Accepts an alternate repository and message store for tests.
19
+ def initialize(repository: nil, message_store: nil)
20
+ @repository = repository || ::Rubino::Session::Repository.new
21
+ @message_store = message_store || ::Rubino::Session::Store.new
22
+ end
23
+
24
+ def call(request)
25
+ id = request.params.fetch("id")
26
+ session = @repository.find(id)
27
+ raise NotFoundError.new("session", id) unless session
28
+
29
+ [200, serialize(session)]
30
+ end
31
+
32
+ private
33
+
34
+ def serialize(session)
35
+ {
36
+ id: session[:id],
37
+ title: session[:title],
38
+ instructions: nil,
39
+ created_at: session[:created_at],
40
+ status: session[:status],
41
+ messages: messages_for(session[:id])
42
+ }
43
+ end
44
+
45
+ def messages_for(session_id)
46
+ @message_store.for_session(session_id).map do |m|
47
+ {
48
+ id: m.id,
49
+ role: m.role,
50
+ content: m.content,
51
+ created_at: m.created_at
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Sessions
7
+ # POST /v1/sessions/:id/undo
8
+ # Removes the last user message and everything after it (no re-run).
9
+ # Returns the number of messages deleted.
10
+ #
11
+ # @raise [Rubino::NotFoundError] when the session does not exist.
12
+ # @raise [Rubino::ConflictError] when the session has no user message to undo.
13
+ class UndoOperation
14
+ def self.call(request)
15
+ new.call(request)
16
+ end
17
+
18
+ # Accepts an alternate session repository and message store for tests.
19
+ def initialize(session_repository: nil, message_store: nil)
20
+ @session_repo = session_repository || ::Rubino::Session::Repository.new
21
+ @message_store = message_store || ::Rubino::Session::Store.new
22
+ end
23
+
24
+ def call(request)
25
+ session_id = request.params.fetch("id")
26
+ raise NotFoundError.new("session", session_id) unless @session_repo.find(session_id)
27
+
28
+ last_user = @message_store.last_for_role(session_id, "user")
29
+ raise ConflictError, "nothing to undo" unless last_user
30
+
31
+ removed = @message_store.delete_from_inclusive(session_id, from_id: last_user.id)
32
+ [200, { removed_messages: removed }]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Skills
7
+ # GET /v1/skills
8
+ # Lists every registered skill annotated with its persisted enabled flag.
9
+ class ListOperation
10
+ def self.call(request)
11
+ new.call(request)
12
+ end
13
+
14
+ # Accepts an alternate skills registry and state repository for tests.
15
+ def initialize(registry: nil, state_repository: nil)
16
+ @registry = registry || ::Rubino::Skills::Registry.new
17
+ @state_repository = state_repository || ::Rubino::Skills::StateRepository.new
18
+ end
19
+
20
+ def call(_request)
21
+ skills = @registry.all.map do |skill|
22
+ {
23
+ name: skill.name,
24
+ description: skill.description,
25
+ enabled: @state_repository.enabled?(skill.name)
26
+ }
27
+ end
28
+ [200, skills]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Skills
7
+ # PUT /v1/skills/:name
8
+ # Persists the enable/disable flag for a single registered skill.
9
+ #
10
+ # @raise [Rubino::NotFoundError] when no skill is registered under +name+.
11
+ # @raise [Rubino::ValidationError] when the body fails Schemas::ToggleSkill.
12
+ class ToggleOperation
13
+ def self.call(request)
14
+ new.call(request)
15
+ end
16
+
17
+ # Accepts an alternate skills registry and state repository for tests.
18
+ def initialize(registry: nil, state_repository: nil)
19
+ @registry = registry || ::Rubino::Skills::Registry.new
20
+ @state_repository = state_repository || ::Rubino::Skills::StateRepository.new
21
+ end
22
+
23
+ def call(request)
24
+ name = request.params.fetch("name")
25
+ raise NotFoundError.new("skill", name) unless @registry.find(name)
26
+
27
+ attrs = request.validate!(Schemas::ToggleSkill)
28
+ # The shared toggle write path (Skills::Toggle, #188) — the same
29
+ # registry-validated StateRepository write the in-chat
30
+ # `/skills enable|disable` and the `rubino skills` CLI verbs run.
31
+ ::Rubino::Skills::Toggle.set(name, enabled: attrs[:enabled],
32
+ registry: @registry,
33
+ state_repository: @state_repository)
34
+ [200, { name: name, enabled: attrs[:enabled] }]
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Tasks
7
+ # GET /v1/tasks
8
+ # Lists background subagents started by the `task` tool, newest first.
9
+ # Each row is the summary shape (no full result body — see the show
10
+ # endpoint for that). The registry is process-local and not persisted,
11
+ # so this reflects only the current server process's children.
12
+ class IndexOperation
13
+ def self.call(request)
14
+ new.call(request)
15
+ end
16
+
17
+ # Accepts an alternate registry for tests.
18
+ def initialize(registry: nil)
19
+ @registry = registry || ::Rubino::Tools::BackgroundTasks.instance
20
+ end
21
+
22
+ def call(_request)
23
+ tasks = @registry.list.map { |entry| Serializer.summary(entry) }
24
+ [200, { tasks: tasks }]
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end