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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Agent
5
+ # Stitches a response truncated by the output-token limit back together.
6
+ #
7
+ # Faithful port of the reference finish_reason=="length" continuation
8
+ # (the per-turn loop plus the boosted output budget). When a model call comes back with
9
+ # stop_reason==:length and NO tool calls, the answer was cut mid-sentence by
10
+ # max_tokens. Rather than surface the fragment as the final turn, we:
11
+ #
12
+ # 1. keep the interim partial as an assistant message in the history,
13
+ # 2. append a "[System: …continue exactly where you left off…]" user nudge,
14
+ # 3. re-issue the SAME request with a progressively BOOSTED output budget
15
+ # (base × (retry+1), capped at 32 768), and
16
+ # 4. concatenate the partial pieces into the final answer.
17
+ #
18
+ # Up to MAX_RETRIES (3, matching the reference `length_continue_retries < 3`)
19
+ # continuations are attempted; if it is still truncated after that, the
20
+ # stitched-together partial is returned as-is (the reference returns it with
21
+ # partial=True / "remained truncated after 3 continuation attempts").
22
+ #
23
+ # The class is transport-agnostic: it issues each continuation through a
24
+ # +boundary+ callable (`boundary.call(request) -> AdapterResponse`) so it
25
+ # unit-tests against fixtures with no network. The caller (Loop) builds the
26
+ # first request and passes the first response in.
27
+ class TruncationContinuation
28
+ # The `length_continue_retries < 3` ceiling.
29
+ MAX_RETRIES = 3
30
+ # Fallback base when agent.max_tokens is unset.
31
+ DEFAULT_BASE = 4096
32
+ # Boost cap.
33
+ BOOST_CAP = 32_768
34
+
35
+ # The continuation nudge for an ordinary output-length truncation
36
+ # (the `else` branch of the continuation-prompt builder). The
37
+ # partial-stream-stub variants don't apply here — a dropped stream surfaces
38
+ # as AdapterResponse#interrupted?, handled separately by the Loop.
39
+ CONTINUATION_NUDGE =
40
+ "[System: Your previous response was truncated by the output " \
41
+ "length limit. Continue exactly where you left off. Do not " \
42
+ "restart or repeat prior text. Finish the answer directly.]"
43
+
44
+ # +boundary+ : responds to #call(request, &block) → AdapterResponse.
45
+ # +base_tokens+ : the configured agent.max_tokens (nil ⇒ DEFAULT_BASE).
46
+ # +ui+ : optional, gets #note on each continuation attempt.
47
+ def initialize(boundary:, base_tokens: nil, ui: nil)
48
+ @boundary = boundary
49
+ @base_tokens = base_tokens
50
+ @ui = ui
51
+ end
52
+
53
+ # True iff +response+ is a length-truncated turn that warrants continuation:
54
+ # stopped on the output limit AND carries no tool calls (a truncated
55
+ # tool-call turn is a different repair path — out of scope here, as in
56
+ # the reference's separate truncated_tool_call branch).
57
+ def applicable?(response)
58
+ response&.stop_reason == :length && !response.has_tool_calls?
59
+ end
60
+
61
+ # Drive the continuation loop. +request+ is the LLM::Request that produced
62
+ # +first_response+; +first_response+ is the truncated AdapterResponse.
63
+ # Re-issues with a boosted budget until the model stops cleanly or
64
+ # MAX_RETRIES is hit, then returns ONE AdapterResponse whose content is the
65
+ # stitched-together answer. A passed block forwards stream chunks straight
66
+ # through to the boundary on each continuation call.
67
+ #
68
+ # If +first_response+ is not applicable? this returns it untouched, so the
69
+ # Loop can call #continue unconditionally.
70
+ def continue(request, first_response, &)
71
+ return first_response unless applicable?(first_response)
72
+
73
+ parts = collect_part(first_response)
74
+ response = first_response
75
+ retries = 0
76
+
77
+ while applicable?(response) && retries < MAX_RETRIES
78
+ retries += 1
79
+ @ui&.note("↻ Requesting continuation (#{retries}/#{MAX_RETRIES})…")
80
+
81
+ # Keep the interim partial in history, then nudge the model to resume.
82
+ messages = request.messages.dup
83
+ messages << { role: "assistant", content: response.content.to_s }
84
+ messages << { role: "user", content: CONTINUATION_NUDGE }
85
+
86
+ request = reissue(request, messages, retries)
87
+ response = @boundary.call(request, &)
88
+ parts.concat(collect_part(response))
89
+ end
90
+
91
+ stitch(response, parts)
92
+ end
93
+
94
+ private
95
+
96
+ # Build the next request: same shape, continued history, boosted budget.
97
+ def reissue(request, messages, retries)
98
+ LLM::Request.new(
99
+ messages: messages,
100
+ tools: request.tools,
101
+ temperature: request.temperature,
102
+ max_tokens: boosted_max_tokens(retries),
103
+ thinking: request.thinking,
104
+ prefill: request.prefill,
105
+ image_paths: request.image_paths,
106
+ stream: request.stream?
107
+ )
108
+ end
109
+
110
+ # Progressive boost: base × (retries+1), capped. On the
111
+ # first continuation (retries==1) the budget is 2× base, then 3×, …
112
+ def boosted_max_tokens(retries)
113
+ base = @base_tokens && @base_tokens.positive? ? @base_tokens : DEFAULT_BASE
114
+ [base * (retries + 1), BOOST_CAP].min
115
+ end
116
+
117
+ def collect_part(response)
118
+ text = response.content.to_s
119
+ text.empty? ? [] : [text]
120
+ end
121
+
122
+ # Final stitched response: concatenated content, carrying the LAST call's
123
+ # token usage / model id / stop_reason (the spend already happened, and the
124
+ # final stop_reason tells the caller whether it ever completed cleanly).
125
+ def stitch(last_response, parts)
126
+ LLM::AdapterResponse.new(
127
+ content: parts.join,
128
+ tool_calls: last_response.tool_calls,
129
+ input_tokens: last_response.input_tokens,
130
+ output_tokens: last_response.output_tokens,
131
+ model_id: last_response.model_id,
132
+ stop_reason: last_response.stop_reason
133
+ )
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/utils"
4
+
5
+ module Rubino
6
+ module API
7
+ module Middleware
8
+ # Bearer-token auth middleware. Sits between JsonParser and the router so
9
+ # unauthorized requests never reach an operation; raises UnauthorizedError
10
+ # which ErrorHandler (one layer up) maps to a 401 JSON response.
11
+ #
12
+ # Token comparison uses Rack::Utils.secure_compare to avoid timing leaks.
13
+ # SKIP_PATHS allows unauthenticated access to liveness/metrics endpoints
14
+ # so external probes don't need to carry the API key.
15
+ class Auth
16
+ SKIP_PATHS = %w[/v1/health /v1/metrics].freeze
17
+
18
+ def initialize(app, api_key:)
19
+ @app = app
20
+ @api_key = api_key
21
+ end
22
+
23
+ def call(env)
24
+ return @app.call(env) if SKIP_PATHS.include?(env["PATH_INFO"])
25
+
26
+ header = env["HTTP_AUTHORIZATION"].to_s
27
+ # RFC 6750: scheme is case-insensitive, separated from the token by a
28
+ # single space. Match explicitly so a raw token without the "Bearer "
29
+ # prefix is rejected instead of being silently accepted (which is what
30
+ # String#sub would do when the pattern doesn't match).
31
+ match = header.match(/\ABearer (.*)\z/i)
32
+ raise UnauthorizedError, "missing bearer scheme" if match.nil?
33
+
34
+ token = match[1]
35
+ raise UnauthorizedError, "missing bearer token" if token.empty?
36
+ raise UnauthorizedError, "invalid bearer token" unless Rack::Utils.secure_compare(token, @api_key)
37
+
38
+ @app.call(env)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module API
7
+ module Middleware
8
+ # Catches typed Rubino errors and renders them as JSON with the right
9
+ # HTTP status (see STATUS_MAP). Anything else becomes a generic 500 while
10
+ # the full class/message/backtrace are sent to the structured logger,
11
+ # so unhandled crashes never leak internals to clients.
12
+ #
13
+ # Stack position: second from outermost, just inside Observability, so
14
+ # Observability still sees the final status code on the way out.
15
+ class ErrorHandler
16
+ STATUS_MAP = {
17
+ Rubino::NotFoundError => 404,
18
+ Rubino::ValidationError => 422,
19
+ Rubino::UnauthorizedError => 401,
20
+ Rubino::ConflictError => 409,
21
+ Rubino::PayloadTooLargeError => 413,
22
+ Rubino::UpstreamError => 502
23
+ }.freeze
24
+
25
+ def initialize(app, logger:)
26
+ @app = app
27
+ @logger = logger
28
+ end
29
+
30
+ def call(env)
31
+ @app.call(env)
32
+ rescue *STATUS_MAP.keys => e
33
+ base = STATUS_MAP.find { |klass, _| e.is_a?(klass) }
34
+ status = base.last
35
+ render(status, code(e, base.first), e.message, details(e))
36
+ rescue StandardError => e
37
+ @logger.error(event: "api.error.unhandled", error: e.class.name, message: e.message,
38
+ backtrace: e.backtrace&.first(10))
39
+ render(500, "internal_error", "internal server error")
40
+ end
41
+
42
+ private
43
+
44
+ def render(status, code, message, details = nil)
45
+ body = { error: { code: code, message: message } }
46
+ body[:error][:details] = details if details && !details.empty?
47
+ [status, { "content-type" => "application/json" }, [JSON.generate(body)]]
48
+ end
49
+
50
+ # Derives a snake_case error code. Subclasses of typed errors (e.g.
51
+ # Workspace::PathTraversal < ValidationError) collapse to the parent's
52
+ # code so clients see a stable enum keyed off STATUS_MAP, not internal
53
+ # subclass names.
54
+ def code(error, base_class = nil)
55
+ source = (base_class || error.class).name
56
+ source.split("::").last.sub(/Error\z/, "").gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
57
+ end
58
+
59
+ def details(error)
60
+ error.respond_to?(:details) ? error.details : nil
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module API
7
+ module Middleware
8
+ # Parses JSON request bodies once and stashes the result on
9
+ # env["rubino.json"] for Request#body to read. Only POST/PUT/PATCH
10
+ # with an application/json content-type are parsed; everything else
11
+ # gets an empty Hash so operations can rely on the key always existing.
12
+ #
13
+ # Malformed JSON raises ValidationError, which ErrorHandler turns into 422.
14
+ #
15
+ # Body size is capped at api.max_body_bytes (default 5 MiB). Requests
16
+ # that advertise a larger Content-Length, or whose body turns out to
17
+ # exceed the cap mid-read (i.e. Content-Length lied or was absent),
18
+ # are short-circuited to 413 here — ErrorHandler is bypassed because
19
+ # 413 is not part of the typed-error map.
20
+ class JsonParser
21
+ APPLICABLE_METHODS = %w[POST PUT PATCH].freeze
22
+ DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024
23
+
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ if APPLICABLE_METHODS.include?(env["REQUEST_METHOD"]) && json_content?(env)
30
+ limit = max_body_bytes
31
+ return too_large(limit) if content_length_over_limit?(env, limit)
32
+
33
+ body, overflowed = read_capped(env, limit)
34
+ return too_large(limit) if overflowed
35
+
36
+ env["rubino.json"] = parse(body)
37
+ else
38
+ env["rubino.json"] = {}
39
+ end
40
+ @app.call(env)
41
+ end
42
+
43
+ private
44
+
45
+ def json_content?(env)
46
+ env["CONTENT_TYPE"].to_s.start_with?("application/json")
47
+ end
48
+
49
+ def content_length_over_limit?(env, limit)
50
+ declared = env["CONTENT_LENGTH"]
51
+ return false if declared.nil? || declared.empty?
52
+
53
+ declared.to_i > limit
54
+ end
55
+
56
+ # Reads up to limit+1 bytes so we can detect the case where the
57
+ # actual body is larger than Content-Length advertised (or there
58
+ # was no Content-Length at all). The +1 marker is dropped before
59
+ # parsing.
60
+ def read_capped(env, limit)
61
+ input = env["rack.input"]
62
+ return ["", false] if input.nil?
63
+
64
+ buf = input.read(limit + 1)
65
+ return ["", false] if buf.nil?
66
+
67
+ if buf.bytesize > limit
68
+ [nil, true]
69
+ else
70
+ [buf, false]
71
+ end
72
+ end
73
+
74
+ def parse(body)
75
+ return {} if body.nil? || body.empty?
76
+
77
+ JSON.parse(body)
78
+ rescue JSON::ParserError => e
79
+ raise ValidationError.new("malformed JSON body", details: { parse_error: e.message })
80
+ end
81
+
82
+ def max_body_bytes
83
+ value = Rubino.configuration.dig("api", "max_body_bytes")
84
+ value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_BODY_BYTES
85
+ end
86
+
87
+ def too_large(limit)
88
+ payload = {
89
+ error: {
90
+ code: "validation",
91
+ message: "request body too large (max #{limit} bytes)",
92
+ details: { max_bytes: limit }
93
+ }
94
+ }
95
+ [413, { "content-type" => "application/json" }, [JSON.generate(payload)]]
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Middleware
6
+ # Outermost middleware. Wraps every request to:
7
+ # - record http_requests_total{method,path,status} + http_request_duration_seconds
8
+ # - emit one JSON log line (event="api.request") with method, path, status, duration_ms
9
+ #
10
+ # Status comes from the response tuple after ErrorHandler has done its
11
+ # mapping; on a fully unhandled raise we still record status=500 and
12
+ # re-raise so Puma can render whatever it wants. The `path` metric label
13
+ # uses env["rubino.route"] (the matched pattern) when present, to
14
+ # keep Prometheus label cardinality bounded.
15
+ class Observability
16
+ def initialize(app, logger: nil)
17
+ @app = app
18
+ @logger = logger || Rubino.logger
19
+ end
20
+
21
+ def call(env)
22
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
+ status, headers, body = @app.call(env)
24
+ observe(env, status, start)
25
+ [status, headers, body]
26
+ rescue StandardError
27
+ observe(env, 500, start)
28
+ raise
29
+ end
30
+
31
+ private
32
+
33
+ def observe(env, status, start)
34
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
35
+ method = env["REQUEST_METHOD"]
36
+ path = path_for(env)
37
+
38
+ Metrics.counter(:http_requests_total, method: method, path: path, status: status).increment
39
+ Metrics.histogram(:http_request_duration_seconds, method: method, path: path).observe(duration)
40
+
41
+ @logger.info(
42
+ event: "api.request",
43
+ method: method,
44
+ path: env["PATH_INFO"],
45
+ status: status,
46
+ duration_ms: (duration * 1000).round(2)
47
+ )
48
+ end
49
+
50
+ # Use the matched route pattern when the router set it (low-cardinality);
51
+ # fall back to the raw path otherwise (might balloon labels, but is
52
+ # better than nothing for unmatched paths logged as 404).
53
+ def path_for(env)
54
+ env["rubino.route"] || env["PATH_INFO"]
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rubino
6
+ module API
7
+ module Middleware
8
+ # Token-bucket rate limiter, applied BEFORE Auth so that the open
9
+ # endpoints (/v1/health, /v1/metrics) get their own per-IP ceiling and
10
+ # cannot be flooded by an unauthenticated client. Authenticated requests
11
+ # are keyed by the bearer token so a single API key cannot saturate the
12
+ # process by spraying connections from many IPs.
13
+ #
14
+ # Buckets refill linearly over a 60-second window. Storage is a single
15
+ # in-memory hash with monotonic timestamps; safe for a single-process
16
+ # deployment. Multi-process / multi-host needs a shared backend
17
+ # (Redis, etc.) — defer until we actually scale out.
18
+ #
19
+ # On exceed: 429 with the canonical error envelope
20
+ # { error: { code: "rate_limited", message: "...",
21
+ # details: { retry_after_seconds: N } } }
22
+ # and a Retry-After header so well-behaved clients can back off without
23
+ # parsing the body.
24
+ class RateLimit
25
+ DEFAULT_UNAUTH_PER_MINUTE = 60
26
+ DEFAULT_AUTH_PER_MINUTE = 600
27
+ WINDOW_SECONDS = 60.0
28
+
29
+ def initialize(app, clock: nil)
30
+ @app = app
31
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
32
+ @buckets = {}
33
+ @mutex = Mutex.new
34
+ end
35
+
36
+ def call(env)
37
+ return @app.call(env) unless enabled?
38
+
39
+ key, capacity = bucket_for(env)
40
+ allowed, retry_after = consume(key, capacity)
41
+ return too_many(retry_after) unless allowed
42
+
43
+ @app.call(env)
44
+ end
45
+
46
+ private
47
+
48
+ # Authenticated buckets are keyed by the bearer token (so the same key
49
+ # used from many IPs still hits one ceiling); unauthenticated buckets
50
+ # are keyed by remote IP. The auth/unauth split is decided by whether
51
+ # the request advertised a Bearer token at all — Auth itself validates
52
+ # the token later, so an invalid token still gets the unauth bucket
53
+ # via REMOTE_ADDR if we cannot extract one.
54
+ def bucket_for(env)
55
+ token = bearer_token(env)
56
+ if token
57
+ ["auth:#{token}", auth_limit]
58
+ else
59
+ ["ip:#{env["REMOTE_ADDR"] || "unknown"}", unauth_limit]
60
+ end
61
+ end
62
+
63
+ def bearer_token(env)
64
+ header = env["HTTP_AUTHORIZATION"].to_s
65
+ match = header.match(/\ABearer (.+)\z/i)
66
+ match && match[1]
67
+ end
68
+
69
+ # Refill is continuous: tokens accumulate at capacity/window per second,
70
+ # capped at capacity. Each request costs 1 token. Returns
71
+ # [allowed?, retry_after_seconds_when_denied].
72
+ def consume(key, capacity)
73
+ now = @clock.call
74
+ @mutex.synchronize do
75
+ bucket = @buckets[key] ||= { tokens: capacity.to_f, updated_at: now }
76
+ elapsed = now - bucket[:updated_at]
77
+ refill_rate = capacity / WINDOW_SECONDS
78
+ bucket[:tokens] = [bucket[:tokens] + (elapsed * refill_rate), capacity.to_f].min
79
+ bucket[:updated_at] = now
80
+
81
+ if bucket[:tokens] >= 1
82
+ bucket[:tokens] -= 1
83
+ [true, 0]
84
+ else
85
+ # Time until one full token is available again.
86
+ deficit = 1 - bucket[:tokens]
87
+ retry_after = (deficit / refill_rate).ceil
88
+ [false, [retry_after, 1].max]
89
+ end
90
+ end
91
+ end
92
+
93
+ def too_many(retry_after)
94
+ payload = {
95
+ error: {
96
+ code: "rate_limited",
97
+ message: "rate limit exceeded",
98
+ details: { retry_after_seconds: retry_after }
99
+ }
100
+ }
101
+ [
102
+ 429,
103
+ {
104
+ "content-type" => "application/json",
105
+ "retry-after" => retry_after.to_s
106
+ },
107
+ [JSON.generate(payload)]
108
+ ]
109
+ end
110
+
111
+ def enabled?
112
+ value = config_dig("api", "rate_limit_enabled")
113
+ value.nil? || value == true
114
+ end
115
+
116
+ def unauth_limit
117
+ int_or(config_dig("api", "rate_limit_unauth_per_minute"), DEFAULT_UNAUTH_PER_MINUTE)
118
+ end
119
+
120
+ def auth_limit
121
+ int_or(config_dig("api", "rate_limit_auth_per_minute"), DEFAULT_AUTH_PER_MINUTE)
122
+ end
123
+
124
+ def int_or(value, fallback)
125
+ value.is_a?(Integer) && value.positive? ? value : fallback
126
+ end
127
+
128
+ def config_dig(*keys)
129
+ Rubino.configuration.dig(*keys)
130
+ rescue StandardError
131
+ nil
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Approvals
7
+ # POST /v1/runs/:run_id/approvals/:approval_id
8
+ # Resolves a pending approval gate on a paused run by posting the
9
+ # operator's decision through the in-process GateRegistry.
10
+ #
11
+ # @raise [Rubino::NotFoundError] when the run does not exist.
12
+ # @raise [Rubino::ValidationError] when the body fails Schemas::DecideApproval.
13
+ # @raise [Rubino::ConflictError] when the run has no pending gate (already decided or never opened).
14
+ class DecideOperation
15
+ def self.call(request)
16
+ new.call(request)
17
+ end
18
+
19
+ # Accepts an alternate run repository and gate registry for tests.
20
+ def initialize(repository: nil, registry: nil)
21
+ @repository = repository || ::Rubino::Run::Repository.new
22
+ @registry = registry || ::Rubino::Run::GateRegistry
23
+ end
24
+
25
+ def call(request)
26
+ run_id = request.params.fetch("run_id")
27
+ approval_id = request.params.fetch("approval_id")
28
+
29
+ raise NotFoundError.new("run", run_id) unless @repository.find(run_id)
30
+
31
+ attrs = request.validate!(Schemas::DecideApproval)
32
+ gate = @registry.fetch(run_id)
33
+ raise ConflictError, "no pending decisions for run #{run_id}" if gate.nil?
34
+
35
+ # Wrong-run (or replayed) approval_id: the gate never issued it,
36
+ # so refuse — never let an arbitrary id unblock an unrelated await.
37
+ # Duplicate posts return the originally-resolved decision so
38
+ # retries are idempotent (no double-unblock of the run loop).
39
+ status = gate.decide(approval_id, attrs[:decision])
40
+ raise NotFoundError.new("approval", approval_id) if status == :unknown
41
+
42
+ resolved = status == :duplicate ? gate.decision_for(approval_id) : attrs[:decision]
43
+ [200, { approval_id: approval_id, decision: resolved }]
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module Clarifications
7
+ # POST /v1/runs/:run_id/clarifications/:clarify_id
8
+ # Delivers the user's response to a clarification gate that paused the run,
9
+ # using the same in-process GateRegistry as approvals.
10
+ #
11
+ # @raise [Rubino::NotFoundError] when the run does not exist.
12
+ # @raise [Rubino::ValidationError] when the body fails Schemas::DecideClarification.
13
+ # @raise [Rubino::ConflictError] when the run has no pending gate.
14
+ class DecideOperation
15
+ def self.call(request)
16
+ new.call(request)
17
+ end
18
+
19
+ # Accepts an alternate run repository and gate registry for tests.
20
+ def initialize(repository: nil, registry: nil)
21
+ @repository = repository || ::Rubino::Run::Repository.new
22
+ @registry = registry || ::Rubino::Run::GateRegistry
23
+ end
24
+
25
+ def call(request)
26
+ run_id = request.params.fetch("run_id")
27
+ clarify_id = request.params.fetch("clarify_id")
28
+
29
+ raise NotFoundError.new("run", run_id) unless @repository.find(run_id)
30
+
31
+ attrs = request.validate!(Schemas::DecideClarification)
32
+ gate = @registry.fetch(run_id)
33
+ raise ConflictError, "no pending decisions for run #{run_id}" if gate.nil?
34
+
35
+ status = gate.decide(clarify_id, attrs[:response])
36
+ raise NotFoundError.new("clarification", clarify_id) if status == :unknown
37
+
38
+ [200, { clarify_id: clarify_id, accepted: true }]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module API
5
+ module Operations
6
+ module CronJobs
7
+ # POST /v1/jobs
8
+ # Creates a cron job row and registers it with the in-process scheduler.
9
+ # New jobs default to deliver="local" when the client omits it.
10
+ #
11
+ # @return [[Integer, Hash]] 201 + serialized job.
12
+ # @raise [Rubino::ValidationError] when the body fails Schemas::CreateCronJob
13
+ # or carries a cron schedule Fugit cannot parse (#164).
14
+ class CreateOperation
15
+ include ScheduleValidation
16
+
17
+ def self.call(request)
18
+ new.call(request)
19
+ end
20
+
21
+ # Accepts an alternate repository and scheduler for tests.
22
+ def initialize(repository: nil, scheduler: nil)
23
+ @repository = repository || ::Rubino::Jobs::CronJobRepository.new
24
+ @scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
25
+ end
26
+
27
+ def call(request)
28
+ attrs = request.validate!(Schemas::CreateCronJob)
29
+ validate_schedule!(attrs[:schedule])
30
+ job = @repository.create(
31
+ name: attrs[:name],
32
+ schedule: attrs[:schedule],
33
+ prompt: attrs[:prompt],
34
+ skills: attrs[:skills] || [],
35
+ model: attrs[:model],
36
+ provider: attrs[:provider],
37
+ deliver: attrs[:deliver] || "local"
38
+ )
39
+ @scheduler.schedule(job)
40
+ [201, Serializer.call(job)]
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end