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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module Jobs
8
+ # Repository for cron job definitions. Plain CRUD on the +cron_jobs+
9
+ # table; execution is orchestrated by Jobs::Scheduler.
10
+ #
11
+ # The +DELIVERS+ constant documents the accepted values for the
12
+ # +deliver+ column, but it is NOT enforced here: validation of the
13
+ # +local+/+webhook+ enum lives in the dry-schema at the HTTP boundary
14
+ # (see Api::Schemas). Callers that bypass the HTTP layer can insert any
15
+ # string; Scheduler#deliver_if_needed only acts on the exact match
16
+ # +"webhook"+, treating anything else as no-op delivery.
17
+ class CronJobRepository
18
+ DELIVERS = %w[local webhook].freeze
19
+
20
+ def initialize(db: nil)
21
+ @db = db || Rubino.database.db
22
+ end
23
+
24
+ def create(name:, schedule:, prompt:, skills: [], model: nil, provider: nil, deliver: "local", enabled: true)
25
+ now = Time.now.utc.iso8601
26
+ id = SecureRandom.uuid
27
+ @db[:cron_jobs].insert(
28
+ id: id, name: name, schedule: schedule, prompt: prompt,
29
+ skills_json: JSON.generate(skills), model: model, provider: provider,
30
+ deliver: deliver, enabled: enabled, created_at: now, updated_at: now
31
+ )
32
+ find(id)
33
+ end
34
+
35
+ def find(id)
36
+ @db[:cron_jobs].where(id: id).first
37
+ end
38
+
39
+ def list(include_disabled: true)
40
+ ds = @db[:cron_jobs].order(:name)
41
+ ds = ds.where(enabled: true) unless include_disabled
42
+ ds.all
43
+ end
44
+
45
+ # Partial update. Unknown keys are silently dropped (whitelist via slice);
46
+ # +:skills+ accepts an Array of strings and is JSON-encoded into the
47
+ # +skills_json+ column.
48
+ # @return [Hash, nil] the refreshed row, or nil if the id does not exist.
49
+ def update(id, attrs)
50
+ return nil unless find(id)
51
+
52
+ attrs = attrs.transform_keys(&:to_sym).slice(:name, :schedule, :prompt, :skills, :model, :provider, :deliver,
53
+ :enabled)
54
+ attrs[:skills_json] = JSON.generate(attrs.delete(:skills) || []) if attrs.key?(:skills)
55
+ attrs[:updated_at] = Time.now.utc.iso8601
56
+ @db[:cron_jobs].where(id: id).update(attrs)
57
+ find(id)
58
+ end
59
+
60
+ def set_enabled(id, enabled:)
61
+ update(id, enabled: enabled)
62
+ end
63
+
64
+ # Stamps +last_run_at+/+last_run_id+ after Scheduler#fire creates the run.
65
+ def record_run(id, run_id:)
66
+ now = Time.now.utc.iso8601
67
+ @db[:cron_jobs].where(id: id).update(last_run_at: now, last_run_id: run_id, updated_at: now)
68
+ end
69
+
70
+ def destroy!(id)
71
+ @db[:cron_jobs].where(id: id).delete
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Jobs
5
+ module Handlers
6
+ # Cleans up old ended sessions beyond retention period.
7
+ class CleanupSessionsJob
8
+ RETENTION_DAYS = 30
9
+
10
+ def perform(payload)
11
+ retention = payload[:retention_days] || RETENTION_DAYS
12
+ cutoff = (Time.now - (retention * 86_400)).utc.iso8601
13
+
14
+ db = Rubino.database.db
15
+ old_sessions = db[:sessions]
16
+ .where(status: "ended")
17
+ .where { ended_at < cutoff }
18
+ .select(:id)
19
+ .all
20
+
21
+ repo = Session::Repository.new
22
+ old_sessions.each do |s|
23
+ repo.destroy!(s[:id])
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ # Register the handler
32
+ Rubino::Jobs::Registry.register("CleanupSessionsJob", Rubino::Jobs::Handlers::CleanupSessionsJob)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Jobs
5
+ module Handlers
6
+ # Triggers context compaction for a session that exceeded threshold.
7
+ class CompactSessionJob
8
+ def perform(payload)
9
+ session_id = payload[:session_id]
10
+ return unless session_id
11
+
12
+ compressor = Context::Compressor.new(session_id: session_id)
13
+ compressor.compact!
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ # Register the handler
21
+ Rubino::Jobs::Registry.register("CompactSessionJob", Rubino::Jobs::Handlers::CompactSessionJob)
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module Rubino
7
+ module Jobs
8
+ module Handlers
9
+ # Variant B — deterministic post-turn skill distillation.
10
+ #
11
+ # Enqueued from Interaction::Lifecycle#enqueue_post_turn_jobs alongside
12
+ # ExtractMemoryJob. The GATE is fully deterministic (no model call):
13
+ # - the run produced a non-empty final assistant answer (succeeded), AND
14
+ # - the turn used >= TOOL_THRESHOLD tool calls (mirrors the reference "5+"), AND
15
+ # - no existing skill already covers the work (kept simple here:
16
+ # no skill whose name/description shares a salient keyword with the
17
+ # user's task — a fresh skills dir always passes).
18
+ # Only on a gate-PASS do we spend ONE auxiliary-model call to distil the
19
+ # just-finished transcript into a SKILL.md candidate, which we then write.
20
+ # So: +1 LLM call per gate-pass, 0 otherwise.
21
+ class DistillSkillJob
22
+ TOOL_THRESHOLD = Integer(ENV.fetch("RA_DISTILL_TOOL_THRESHOLD", "5"))
23
+
24
+ NAME_RE = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
25
+
26
+ DISTILL_SYSTEM = <<~SYS
27
+ You distil a just-finished agent task into a REUSABLE skill, or decline.
28
+ You are given the user's task and a transcript of the tools the agent ran
29
+ and its final answer. If — and only if — the work was a complex, multi-step,
30
+ REPEATABLE procedure that would help future similar tasks, output a skill.
31
+ If it was trivial, one-off, or not generalizable, decline.
32
+
33
+ Output ONLY a JSON object, no prose:
34
+ {"create": true, "name": "<kebab-case, <=64 chars>",
35
+ "description": "<one line: what it's for and WHEN it applies>",
36
+ "body": "<markdown: # Title then the proven step-by-step instructions, commands, pitfalls — generalized, not hard-coded to this one input>"}
37
+ or {"create": false, "reason": "<why not skill-worthy>"}
38
+ SYS
39
+
40
+ def perform(payload)
41
+ session_id = payload[:session_id] || payload["session_id"]
42
+ return unless session_id
43
+
44
+ messages = Session::Store.new.for_session(session_id)
45
+ return unless gate_passes?(messages)
46
+
47
+ candidate = distill(messages)
48
+ return unless candidate && candidate["create"] == true
49
+
50
+ write_skill(candidate)
51
+ rescue StandardError => e
52
+ Rubino.logger.warn(event: "jobs.distill_skill.error", error_class: e.class.name, message: e.message)
53
+ nil
54
+ end
55
+
56
+ private
57
+
58
+ # Deterministic gate — NO model call here.
59
+ def gate_passes?(messages)
60
+ succeeded?(messages) &&
61
+ tool_count(messages) >= TOOL_THRESHOLD &&
62
+ !already_covered?(messages)
63
+ end
64
+
65
+ def succeeded?(messages)
66
+ final = messages.reverse.find { |m| m.role == "assistant" && !m.content.to_s.strip.empty? }
67
+ !final.nil?
68
+ end
69
+
70
+ def tool_count(messages)
71
+ messages.count { |m| m.role == "tool" }
72
+ end
73
+
74
+ # "No skill already covering it": if the registry is empty, never covered.
75
+ # Otherwise, covered when the user's task shares a salient keyword with an
76
+ # existing skill's name/description. Deterministic, cheap, no model call.
77
+ def already_covered?(messages)
78
+ skills = registry.all
79
+ return false if skills.empty?
80
+
81
+ task = first_user_text(messages).to_s.downcase
82
+ task_words = task.scan(/[a-z]{4,}/).to_set
83
+ skills.any? do |s|
84
+ hay = "#{s.name} #{s.description}".downcase
85
+ hay.scan(/[a-z]{4,}/).any? { |w| task_words.include?(w) }
86
+ end
87
+ end
88
+
89
+ def first_user_text(messages)
90
+ messages.find { |m| m.role == "user" }&.content
91
+ end
92
+
93
+ # The single auxiliary-model call (counts as the +1 LLM call).
94
+ def distill(messages)
95
+ transcript = build_transcript(messages)
96
+ response = LLM::AuxiliaryClient.new.call(
97
+ task: "summarize",
98
+ messages: [
99
+ { role: "system", content: DISTILL_SYSTEM },
100
+ { role: "user", content: transcript }
101
+ ]
102
+ )
103
+ extract_json(response.content.to_s)
104
+ end
105
+
106
+ def build_transcript(messages)
107
+ parts = []
108
+ messages.each do |m|
109
+ case m.role
110
+ when "user"
111
+ parts << "USER TASK:\n#{m.content}"
112
+ when "tool"
113
+ parts << "TOOL #{m.tool_name if m.respond_to?(:tool_name)}: #{m.content.to_s[0, 400]}"
114
+ when "assistant"
115
+ next if m.content.to_s.strip.empty?
116
+
117
+ parts << "ASSISTANT: #{m.content.to_s[0, 800]}"
118
+ end
119
+ end
120
+ parts.join("\n\n")[0, 8000]
121
+ end
122
+
123
+ def extract_json(text)
124
+ start = text.index("{")
125
+ return nil unless start
126
+
127
+ depth = 0
128
+ (start...text.length).each do |i|
129
+ depth += 1 if text[i] == "{"
130
+ if text[i] == "}"
131
+ depth -= 1
132
+ return JSON.parse(text[start..i]) if depth.zero?
133
+ end
134
+ end
135
+ nil
136
+ rescue JSON::ParserError
137
+ nil
138
+ end
139
+
140
+ def write_skill(candidate)
141
+ name = candidate["name"].to_s.strip
142
+ desc = candidate["description"].to_s.tr("\n", " ").strip
143
+ body = candidate["body"].to_s
144
+ return unless valid?(name, desc, body)
145
+ return if registry.find(name) # don't overwrite
146
+
147
+ dir = File.join(skills_write_dir, name)
148
+ FileUtils.mkdir_p(dir)
149
+ path = File.join(dir, "SKILL.md")
150
+ content = "---\nname: #{name}\ndescription: #{yaml_scalar(desc)}\n---\n\n#{body}"
151
+ content << "\n" unless content.end_with?("\n")
152
+ File.write(path, content)
153
+
154
+ Metrics.counter(:skills_created_total).increment
155
+ Rubino.active_event_bus&.emit(
156
+ Interaction::Events::SKILL_CREATED, name: name, file_path: path
157
+ )
158
+ path
159
+ end
160
+
161
+ def valid?(name, desc, body)
162
+ name.match?(NAME_RE) && name.length <= 64 &&
163
+ !desc.empty? && desc.length <= 1024 && !body.strip.empty?
164
+ end
165
+
166
+ def yaml_scalar(text)
167
+ %("#{text.gsub('"', '\\"')}")
168
+ end
169
+
170
+ def skills_write_dir
171
+ dir = (Rubino.configuration.dig("skills", "paths") || [".rubino/skills"]).first
172
+ File.expand_path(dir.to_s)
173
+ end
174
+
175
+ def registry
176
+ @registry ||= Skills::Registry.new
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ # Register the handler
184
+ Rubino::Jobs::Registry.register(
185
+ "DistillSkillJob", Rubino::Jobs::Handlers::DistillSkillJob
186
+ )
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Jobs
5
+ module Handlers
6
+ # Extracts memories from a completed session turn.
7
+ class ExtractMemoryJob
8
+ def perform(payload)
9
+ session_id = payload[:session_id]
10
+ return unless session_id
11
+
12
+ confirm(Memory::Backends.build.extract(session_id))
13
+ end
14
+
15
+ private
16
+
17
+ # Deterministic save confirmation (#87): the agent's "I'll remember X"
18
+ # narration is no signal that anything landed. Echo one line from the
19
+ # actual write path, mirroring the memory tool's
20
+ # "✓ done · memory · Memory added (id=…)" line in chat. Best-effort — a
21
+ # UI hiccup must never fail (and re-run) a job whose writes landed.
22
+ def confirm(stored)
23
+ facts = Array(stored).compact
24
+ return if facts.empty?
25
+
26
+ ids = facts.map { |f| f[:id].to_s[0, 8] }.join(", ")
27
+ Rubino.ui.note("✓ saved to memory · #{facts.size} fact#{"s" if facts.size != 1} (#{ids})")
28
+ rescue StandardError
29
+ nil
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ # Register the handler
37
+ Rubino::Jobs::Registry.register("ExtractMemoryJob", Rubino::Jobs::Handlers::ExtractMemoryJob)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Jobs
5
+ module Handlers
6
+ # Generates or updates a session summary.
7
+ class SummarizeSessionJob
8
+ def perform(payload)
9
+ session_id = payload[:session_id]
10
+ return unless session_id
11
+
12
+ builder = Context::SummaryBuilder.new(session_id: session_id)
13
+ builder.build_and_save!
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ # Register the handler
21
+ Rubino::Jobs::Registry.register("SummarizeSessionJob", Rubino::Jobs::Handlers::SummarizeSessionJob)
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module Jobs
8
+ # Manages the job queue backed by SQLite.
9
+ # Supports enqueue, dequeue, locking, and status queries.
10
+ class Queue
11
+ def initialize(db: nil, config: nil)
12
+ @db = db || Rubino.database.db
13
+ @config = config || Rubino.configuration
14
+ end
15
+
16
+ # Enqueues a new job
17
+ def enqueue(type, payload, priority: 100, run_at: nil)
18
+ now = Time.now.utc.iso8601
19
+ id = SecureRandom.uuid
20
+
21
+ @db[:jobs].insert(
22
+ id: id,
23
+ type: type,
24
+ status: "queued",
25
+ priority: priority,
26
+ payload_json: JSON.generate(payload),
27
+ attempts: 0,
28
+ max_attempts: @config.jobs_max_attempts,
29
+ run_at: run_at || now,
30
+ created_at: now,
31
+ updated_at: now
32
+ )
33
+
34
+ # If inline mode, execute immediately — but first drain any stale rows
35
+ # a previous inline run left orphaned (#84/#224). Inline mode has no
36
+ # background drainer, so a `queued` row whose enqueuing process was
37
+ # interrupted mid-run (e.g. the user quit the session while the
38
+ # post-turn extraction was still finishing) — run_job is called
39
+ # directly, never locked, and Interrupt is not a StandardError, so the
40
+ # row never reaches complete!/fail! — sits "queued" forever and is the
41
+ # behaviour #84 closed. Every inline enqueue means a live process is
42
+ # here and willing to drain, so reap those orphans on this boot.
43
+ if @config.jobs_mode == "inline"
44
+ reap_inline_orphans(before: id)
45
+ Runner.new.run_job(id)
46
+ end
47
+
48
+ id
49
+ end
50
+
51
+ # Dequeues the next available job (locks it)
52
+ def dequeue(worker_id:)
53
+ now = Time.now.utc.iso8601
54
+
55
+ job = @db[:jobs]
56
+ .where(status: "queued")
57
+ .where { run_at <= now }
58
+ .order(:priority, :run_at)
59
+ .first
60
+
61
+ return nil unless job
62
+
63
+ # Lock the job
64
+ updated = @db[:jobs]
65
+ .where(id: job[:id], status: "queued")
66
+ .update(
67
+ status: "running",
68
+ locked_at: now,
69
+ locked_by: worker_id,
70
+ updated_at: now
71
+ )
72
+
73
+ # Return nil if another worker grabbed it first
74
+ updated > 0 ? @db[:jobs].where(id: job[:id]).first : nil
75
+ end
76
+
77
+ # Marks a job as completed
78
+ def complete!(job_id)
79
+ @db[:jobs].where(id: job_id).update(
80
+ status: "completed",
81
+ locked_at: nil,
82
+ locked_by: nil,
83
+ updated_at: Time.now.utc.iso8601
84
+ )
85
+ end
86
+
87
+ # Marks a job as failed, increments attempts
88
+ def fail!(job_id, error:)
89
+ job = @db[:jobs].where(id: job_id).first
90
+ return unless job
91
+
92
+ new_attempts = job[:attempts] + 1
93
+ # Inline mode has no background drainer, so re-queueing a failed job
94
+ # would leave it "queued" forever (#84) — mark it terminal ("failed")
95
+ # instead so `jobs list` is honest. Worker/manual modes keep the
96
+ # retry-with-backoff behavior until attempts are exhausted.
97
+ new_status =
98
+ if new_attempts >= job[:max_attempts]
99
+ "dead"
100
+ elsif @config.jobs_mode == "inline"
101
+ "failed"
102
+ else
103
+ "queued"
104
+ end
105
+
106
+ # Calculate retry time with backoff
107
+ backoff = @config.dig("jobs", "retry_backoff_seconds") || 30
108
+ retry_at = (Time.now + (backoff * new_attempts)).utc.iso8601
109
+
110
+ @db[:jobs].where(id: job_id).update(
111
+ status: new_status,
112
+ attempts: new_attempts,
113
+ last_error: error,
114
+ locked_at: nil,
115
+ locked_by: nil,
116
+ run_at: new_status == "queued" ? retry_at : job[:run_at],
117
+ updated_at: Time.now.utc.iso8601
118
+ )
119
+ end
120
+
121
+ # Lists jobs with optional filters
122
+ def list(status: nil, limit: 20)
123
+ dataset = @db[:jobs].order(Sequel.desc(:created_at)).limit(limit)
124
+ dataset = dataset.where(status: status) if status
125
+ dataset.all
126
+ end
127
+
128
+ # Finds one job by full id or short-id prefix (the 8-char ids the list
129
+ # renders — same prefix resolution the memory store gives /memory show).
130
+ # nil when nothing matches; the first match wins on an ambiguous prefix.
131
+ def find(id)
132
+ return nil if id.to_s.empty?
133
+
134
+ @db[:jobs].where(Sequel.like(:id, "#{id}%")).first
135
+ end
136
+
137
+ # Status counts for the whole queue (status => count), one grouped
138
+ # query — the in-chat /jobs header line (#187). {} when the queue is empty.
139
+ def counts
140
+ @db[:jobs].group_and_count(:status).to_h { |row| [row[:status], row[:count]] }
141
+ end
142
+
143
+ # Returns count of pending jobs
144
+ def pending_count
145
+ @db[:jobs].where(status: "queued").count
146
+ end
147
+
148
+ # Returns count of failed jobs — both the inline-mode terminal "failed"
149
+ # and the attempts-exhausted "dead" (the two states a human must act on;
150
+ # surfaced by the in-chat /status jobs line, #186).
151
+ def failed_count
152
+ @db[:jobs].where(status: %w[failed dead]).count
153
+ end
154
+
155
+ # Drains `queued` rows left orphaned by an interrupted prior inline run
156
+ # (#84/#224). Runs every still-queued, due, unlocked row that was
157
+ # enqueued before +before+ (the row this enqueue is about to run itself),
158
+ # so a turn whose extraction was interrupted is recovered on the next
159
+ # inline boot instead of sitting "queued" forever. Each is taken through
160
+ # run_job, which marks it completed / failed (inline) / dead terminally.
161
+ def reap_inline_orphans(before: nil)
162
+ now = Time.now.utc.iso8601
163
+ runner = Runner.new(db: @db)
164
+
165
+ dataset = @db[:jobs]
166
+ .where(status: "queued", locked_by: nil)
167
+ .where { run_at <= now }
168
+ .order(:priority, :run_at)
169
+ dataset = dataset.exclude(id: before) if before
170
+
171
+ dataset.select_map(:id).each { |orphan_id| runner.run_job(orphan_id) }
172
+ end
173
+
174
+ # Cleans up old completed jobs
175
+ def cleanup!(older_than_days: 7)
176
+ cutoff = (Time.now - (older_than_days * 86_400)).utc.iso8601
177
+ @db[:jobs]
178
+ .where(status: "completed")
179
+ .where { created_at < cutoff }
180
+ .delete
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Jobs
5
+ # Registry that maps job type strings to handler classes.
6
+ class Registry
7
+ @handlers = {}
8
+
9
+ class << self
10
+ # Registers a handler class for a job type
11
+ def register(type, handler_class)
12
+ @handlers[type.to_s] = handler_class
13
+ end
14
+
15
+ # Returns the handler class for a job type. Job types name classes
16
+ # under Jobs::Handlers, so an unregistered type is resolved straight
17
+ # from that namespace (triggering the Zeitwerk autoload) and cached.
18
+ # This makes lookup independent of load order: a handler can never be
19
+ # "unregistered" at run time just because nothing happened to touch
20
+ # its constant before the inline Runner executed at enqueue time (#81).
21
+ def handler_for(type)
22
+ @handlers[type.to_s] || resolve(type.to_s)
23
+ end
24
+
25
+ # Returns all registered job types
26
+ def registered_types
27
+ @handlers.keys
28
+ end
29
+
30
+ # Clears all registrations (useful for testing)
31
+ def reset!
32
+ @handlers = {}
33
+ end
34
+
35
+ private
36
+
37
+ def resolve(type)
38
+ @handlers[type] = Handlers.const_get(type, false)
39
+ rescue NameError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module Jobs
7
+ # Executes individual jobs by looking up handlers in the Registry.
8
+ class Runner
9
+ def initialize(db: nil)
10
+ @db = db || Rubino.database.db
11
+ @queue = Queue.new(db: @db)
12
+ end
13
+
14
+ # Runs a specific job by ID
15
+ def run_job(job_id)
16
+ job = @db[:jobs].where(id: job_id).first
17
+ return unless job
18
+
19
+ handler = Registry.handler_for(job[:type])
20
+ unless handler
21
+ @queue.fail!(job_id, error: "No handler registered for: #{job[:type]}")
22
+ return
23
+ end
24
+
25
+ payload = JSON.parse(job[:payload_json], symbolize_names: true)
26
+ run_id = record_run_start(job_id)
27
+
28
+ begin
29
+ Rubino.event_bus.emit(Interaction::Events::JOB_STARTED, type: job[:type])
30
+ handler.new.perform(payload)
31
+ @queue.complete!(job_id)
32
+ record_run_finish(run_id, status: "completed")
33
+ Rubino.event_bus.emit(Interaction::Events::JOB_FINISHED, type: job[:type])
34
+ rescue StandardError => e
35
+ @queue.fail!(job_id, error: e.message)
36
+ record_run_finish(run_id, status: "failed", error: e.message)
37
+ Rubino.event_bus.emit(Interaction::Events::JOB_FAILED, type: job[:type], error: e.message)
38
+ end
39
+ end
40
+
41
+ # Runs all pending jobs up to limit
42
+ def run_pending(limit: 10)
43
+ worker_id = "runner-#{Process.pid}"
44
+ processed = 0
45
+
46
+ limit.times do
47
+ job = @queue.dequeue(worker_id: worker_id)
48
+ break unless job
49
+
50
+ run_job(job[:id])
51
+ processed += 1
52
+ end
53
+
54
+ processed
55
+ end
56
+
57
+ private
58
+
59
+ def record_run_start(job_id)
60
+ id = SecureRandom.uuid
61
+ @db[:job_runs].insert(
62
+ id: id,
63
+ job_id: job_id,
64
+ status: "running",
65
+ started_at: Time.now.utc.iso8601
66
+ )
67
+ id
68
+ end
69
+
70
+ def record_run_finish(run_id, status:, error: nil)
71
+ @db[:job_runs].where(id: run_id).update(
72
+ status: status,
73
+ finished_at: Time.now.utc.iso8601,
74
+ error: error
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end