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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module CronJobs
|
|
7
|
+
# DELETE /v1/jobs/:id
|
|
8
|
+
# Unschedules the cron job from the in-process scheduler before deleting
|
|
9
|
+
# the row, so no stray ticks fire post-delete.
|
|
10
|
+
#
|
|
11
|
+
# @return [[Integer, Hash]] 204 No Content.
|
|
12
|
+
# @raise [Rubino::NotFoundError] when the cron job does not exist.
|
|
13
|
+
class DeleteOperation
|
|
14
|
+
def self.call(request)
|
|
15
|
+
new.call(request)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Accepts an alternate repository and scheduler for tests.
|
|
19
|
+
def initialize(repository: nil, scheduler: nil)
|
|
20
|
+
@repository = repository || ::Rubino::Jobs::CronJobRepository.new
|
|
21
|
+
@scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(request)
|
|
25
|
+
id = request.params.fetch("id")
|
|
26
|
+
raise NotFoundError.new("cron_job", id) unless @repository.find(id)
|
|
27
|
+
|
|
28
|
+
@scheduler.unschedule(id)
|
|
29
|
+
@repository.destroy!(id)
|
|
30
|
+
Responses.no_content
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module API
|
|
7
|
+
module Operations
|
|
8
|
+
module CronJobs
|
|
9
|
+
# GET /v1/jobs
|
|
10
|
+
# Lists cron jobs. Disabled jobs are included by default; pass
|
|
11
|
+
# ?include_disabled=false to hide them.
|
|
12
|
+
class ListOperation
|
|
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::Jobs::CronJobRepository.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request)
|
|
23
|
+
include_disabled = request.query["include_disabled"] != "false"
|
|
24
|
+
jobs = @repository.list(include_disabled: include_disabled).map { |j| Serializer.call(j) }
|
|
25
|
+
[200, jobs]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Shared serializer used by every CronJobs operation so the wire shape
|
|
30
|
+
# (and the JSON-decoded skills array) stays consistent.
|
|
31
|
+
module Serializer
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def call(job)
|
|
35
|
+
{
|
|
36
|
+
id: job[:id],
|
|
37
|
+
name: job[:name],
|
|
38
|
+
schedule: job[:schedule],
|
|
39
|
+
prompt: job[:prompt],
|
|
40
|
+
skills: job[:skills_json] ? JSON.parse(job[:skills_json]) : [],
|
|
41
|
+
model: job[:model],
|
|
42
|
+
provider: job[:provider],
|
|
43
|
+
deliver: job[:deliver],
|
|
44
|
+
enabled: job[:enabled] == true,
|
|
45
|
+
last_run_at: job[:last_run_at],
|
|
46
|
+
last_run_id: job[:last_run_id],
|
|
47
|
+
created_at: job[:created_at],
|
|
48
|
+
updated_at: job[:updated_at]
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module CronJobs
|
|
7
|
+
# POST /v1/jobs/:id/pause
|
|
8
|
+
# Flips enabled=false on the cron job and unschedules its tick. Idempotent.
|
|
9
|
+
#
|
|
10
|
+
# @raise [Rubino::NotFoundError] when the cron job does not exist.
|
|
11
|
+
class PauseOperation
|
|
12
|
+
def self.call(request)
|
|
13
|
+
new.call(request)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Accepts an alternate repository and scheduler for tests.
|
|
17
|
+
def initialize(repository: nil, scheduler: nil)
|
|
18
|
+
@repository = repository || ::Rubino::Jobs::CronJobRepository.new
|
|
19
|
+
@scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request)
|
|
23
|
+
id = request.params.fetch("id")
|
|
24
|
+
raise NotFoundError.new("cron_job", id) unless @repository.find(id)
|
|
25
|
+
|
|
26
|
+
updated = @repository.set_enabled(id, enabled: false)
|
|
27
|
+
@scheduler.unschedule(id)
|
|
28
|
+
[200, Serializer.call(updated)]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module CronJobs
|
|
7
|
+
# POST /v1/jobs/:id/resume
|
|
8
|
+
# Flips enabled=true on the cron job and (re)registers its tick. Idempotent.
|
|
9
|
+
#
|
|
10
|
+
# @raise [Rubino::NotFoundError] when the cron job does not exist.
|
|
11
|
+
class ResumeOperation
|
|
12
|
+
def self.call(request)
|
|
13
|
+
new.call(request)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Accepts an alternate repository and scheduler for tests.
|
|
17
|
+
def initialize(repository: nil, scheduler: nil)
|
|
18
|
+
@repository = repository || ::Rubino::Jobs::CronJobRepository.new
|
|
19
|
+
@scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request)
|
|
23
|
+
id = request.params.fetch("id")
|
|
24
|
+
raise NotFoundError.new("cron_job", id) unless @repository.find(id)
|
|
25
|
+
|
|
26
|
+
updated = @repository.set_enabled(id, enabled: true)
|
|
27
|
+
@scheduler.schedule(updated)
|
|
28
|
+
[200, Serializer.call(updated)]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fugit"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module API
|
|
7
|
+
module Operations
|
|
8
|
+
module CronJobs
|
|
9
|
+
# Pre-flight cron validation shared by Create/Update (#164). A schedule
|
|
10
|
+
# Fugit cannot parse must be rejected BEFORE the row is persisted: a
|
|
11
|
+
# committed bad row used to 500 the request AND poison the next boot
|
|
12
|
+
# (Scheduler#load_all! raised on it and the server never bound).
|
|
13
|
+
module ScheduleValidation
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def validate_schedule!(schedule)
|
|
17
|
+
return if schedule.nil? || Fugit.parse_cron(schedule)
|
|
18
|
+
|
|
19
|
+
# Same envelope shape as Request#validate! so clients see one
|
|
20
|
+
# canonical 422 format: error.details.errors.<field> => [messages].
|
|
21
|
+
raise ValidationError.new(
|
|
22
|
+
"invalid request body",
|
|
23
|
+
details: { errors: { schedule: ["is not a valid cron expression"] } }
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module CronJobs
|
|
7
|
+
# GET /v1/jobs/:id
|
|
8
|
+
# Fetches a single cron job by id.
|
|
9
|
+
#
|
|
10
|
+
# @raise [Rubino::NotFoundError] when the cron job does not exist.
|
|
11
|
+
class ShowOperation
|
|
12
|
+
def self.call(request)
|
|
13
|
+
new.call(request)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Accepts an alternate repository for tests.
|
|
17
|
+
def initialize(repository: nil)
|
|
18
|
+
@repository = repository || ::Rubino::Jobs::CronJobRepository.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(request)
|
|
22
|
+
id = request.params.fetch("id")
|
|
23
|
+
job = @repository.find(id)
|
|
24
|
+
raise NotFoundError.new("cron_job", id) unless job
|
|
25
|
+
|
|
26
|
+
[200, Serializer.call(job)]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module CronJobs
|
|
7
|
+
# POST /v1/jobs/:id/trigger
|
|
8
|
+
# Forces an off-cycle execution of the cron job through the scheduler
|
|
9
|
+
# and returns the new run/session ids.
|
|
10
|
+
#
|
|
11
|
+
# @return [[Integer, Hash]] 202 + { job_id, run_id, session_id }.
|
|
12
|
+
# @raise [Rubino::NotFoundError] when the cron job does not exist.
|
|
13
|
+
# @raise [Rubino::ConflictError] when the scheduler refuses to dispatch (returns nil).
|
|
14
|
+
class TriggerOperation
|
|
15
|
+
def self.call(request)
|
|
16
|
+
new.call(request)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Accepts an alternate repository and scheduler for tests.
|
|
20
|
+
def initialize(repository: nil, scheduler: nil)
|
|
21
|
+
@repository = repository || ::Rubino::Jobs::CronJobRepository.new
|
|
22
|
+
@scheduler = scheduler || ::Rubino::Jobs::Scheduler.instance
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(request)
|
|
26
|
+
id = request.params.fetch("id")
|
|
27
|
+
raise NotFoundError.new("cron_job", id) unless @repository.find(id)
|
|
28
|
+
|
|
29
|
+
run = @scheduler.trigger(id)
|
|
30
|
+
raise ConflictError, "trigger failed" if run.nil?
|
|
31
|
+
|
|
32
|
+
[202, { job_id: id, run_id: run[:id], session_id: run[:session_id] }]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module CronJobs
|
|
7
|
+
# PATCH /v1/jobs/:id
|
|
8
|
+
# Applies a partial update and resyncs the scheduler: always unschedule,
|
|
9
|
+
# reschedule only when the resulting row is still enabled.
|
|
10
|
+
#
|
|
11
|
+
# @raise [Rubino::NotFoundError] when the cron job does not exist.
|
|
12
|
+
# @raise [Rubino::ValidationError] when the body fails Schemas::UpdateCronJob
|
|
13
|
+
# or carries a cron schedule Fugit cannot parse (#164).
|
|
14
|
+
class UpdateOperation
|
|
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
|
+
id = request.params.fetch("id")
|
|
29
|
+
raise NotFoundError.new("cron_job", id) unless @repository.find(id)
|
|
30
|
+
|
|
31
|
+
attrs = request.validate!(Schemas::UpdateCronJob)
|
|
32
|
+
validate_schedule!(attrs[:schedule])
|
|
33
|
+
updated = @repository.update(id, attrs)
|
|
34
|
+
@scheduler.unschedule(id)
|
|
35
|
+
@scheduler.schedule(updated) if updated[:enabled]
|
|
36
|
+
[200, Serializer.call(updated)]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module Files
|
|
7
|
+
# GET /v1/files?path=relative/path
|
|
8
|
+
# Streams raw bytes from a path inside the sandboxed workspace as
|
|
9
|
+
# application/octet-stream. Path traversal is enforced by the workspace.
|
|
10
|
+
#
|
|
11
|
+
# @return [[Integer, Hash, Array<String>]] 200 + octet-stream Rack triple.
|
|
12
|
+
# @raise [Rubino::ValidationError] when the +path+ query parameter is missing or empty.
|
|
13
|
+
class ReadOperation
|
|
14
|
+
def self.call(request)
|
|
15
|
+
new.call(request)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Accepts an alternate workspace for tests.
|
|
19
|
+
#
|
|
20
|
+
# Roots the workspace at the SAME directory the tools sandbox to
|
|
21
|
+
# (terminal.cwd || Dir.pwd), not config.paths_home. Tools and
|
|
22
|
+
# attach_file emit absolute paths under that root, so a produced
|
|
23
|
+
# artifact lives there — rooting at paths_home would make every
|
|
24
|
+
# such path look like a traversal escape and 422 the download.
|
|
25
|
+
def initialize(workspace: nil)
|
|
26
|
+
@workspace = workspace || ::Rubino::Files::Workspace.new(root: ::Rubino::Tools::Base.workspace_root)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(request)
|
|
30
|
+
path = request.query["path"]
|
|
31
|
+
raise ValidationError, "path query parameter is required" if path.nil? || path.empty?
|
|
32
|
+
|
|
33
|
+
content = @workspace.read(path)
|
|
34
|
+
[200, { "content-type" => "application/octet-stream" }, [content]]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/multipart"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module API
|
|
7
|
+
module Operations
|
|
8
|
+
module Files
|
|
9
|
+
# POST /v1/files (multipart/form-data, field "file")
|
|
10
|
+
# Persists a single uploaded file into the sandboxed workspace and
|
|
11
|
+
# returns its descriptor (id, filename, size).
|
|
12
|
+
#
|
|
13
|
+
# Multipart payload is capped at api.max_upload_bytes (default 50 MiB).
|
|
14
|
+
# The cap is enforced twice: first against the declared Content-Length
|
|
15
|
+
# (cheap reject before any IO), then by wrapping rack.input so a body
|
|
16
|
+
# that lies about its size — or omits Content-Length entirely — still
|
|
17
|
+
# aborts mid-stream before Rack::Multipart can fully buffer it to disk.
|
|
18
|
+
#
|
|
19
|
+
# @return [[Integer, Hash]] 201 + descriptor payload.
|
|
20
|
+
# @raise [Rubino::ValidationError] when the content-type is not multipart or the "file" field is missing.
|
|
21
|
+
# @raise [Rubino::PayloadTooLargeError] when the body exceeds the cap.
|
|
22
|
+
class UploadOperation
|
|
23
|
+
DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
# Wraps a Rack input stream and raises PayloadTooLargeError once the
|
|
26
|
+
# cumulative bytes read pass +limit+. Rack::Multipart drives the read
|
|
27
|
+
# loop, so raising here unwinds straight out of parse_multipart and
|
|
28
|
+
# the partially-written tempfile is collected by the ensure block
|
|
29
|
+
# in #call.
|
|
30
|
+
class CappedInput
|
|
31
|
+
def initialize(io, limit)
|
|
32
|
+
@io = io
|
|
33
|
+
@limit = limit
|
|
34
|
+
@read = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read(length = nil, buffer = nil)
|
|
38
|
+
chunk = buffer ? @io.read(length, buffer) : @io.read(length)
|
|
39
|
+
return chunk if chunk.nil?
|
|
40
|
+
|
|
41
|
+
@read += chunk.bytesize
|
|
42
|
+
if @read > @limit
|
|
43
|
+
raise Rubino::PayloadTooLargeError.new(
|
|
44
|
+
"multipart upload exceeds #{@limit} bytes",
|
|
45
|
+
details: { limit_bytes: @limit }
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
chunk
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Rack::Multipart::Parser interrogates rewind/eof?/gets on the input;
|
|
53
|
+
# delegate so the parser is unaware it's wrapped.
|
|
54
|
+
def rewind
|
|
55
|
+
@read = 0
|
|
56
|
+
@io.rewind
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def eof?
|
|
60
|
+
@io.eof?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def gets(*)
|
|
64
|
+
line = @io.gets(*)
|
|
65
|
+
return line if line.nil?
|
|
66
|
+
|
|
67
|
+
@read += line.bytesize
|
|
68
|
+
if @read > @limit
|
|
69
|
+
raise Rubino::PayloadTooLargeError.new(
|
|
70
|
+
"multipart upload exceeds #{@limit} bytes",
|
|
71
|
+
details: { limit_bytes: @limit }
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
line
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def respond_to_missing?(name, include_private = false)
|
|
79
|
+
@io.respond_to?(name, include_private) || super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def method_missing(name, *, &)
|
|
83
|
+
return @io.send(name, *, &) if @io.respond_to?(name)
|
|
84
|
+
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.call(request)
|
|
90
|
+
new.call(request)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Accepts an alternate workspace for tests.
|
|
94
|
+
#
|
|
95
|
+
# Roots at the tool workspace (terminal.cwd || Dir.pwd), the same
|
|
96
|
+
# root tools and attach_file use, so uploaded files land where the
|
|
97
|
+
# agent can read them back. See ReadOperation for the rationale.
|
|
98
|
+
def initialize(workspace: nil)
|
|
99
|
+
@workspace = workspace || ::Rubino::Files::Workspace.new(root: ::Rubino::Tools::Base.workspace_root)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def call(request)
|
|
103
|
+
content_type = request.env["CONTENT_TYPE"].to_s
|
|
104
|
+
unless content_type.start_with?("multipart/form-data")
|
|
105
|
+
raise ValidationError,
|
|
106
|
+
"content-type must be multipart/form-data"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
limit = max_upload_bytes
|
|
110
|
+
declared = request.env["CONTENT_LENGTH"].to_s
|
|
111
|
+
if !declared.empty? && declared.to_i > limit
|
|
112
|
+
raise PayloadTooLargeError.new(
|
|
113
|
+
"multipart upload exceeds #{limit} bytes",
|
|
114
|
+
details: { limit_bytes: limit }
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
params = parse_with_cap(request.env, limit)
|
|
119
|
+
upload = params["file"]
|
|
120
|
+
raise ValidationError, "missing 'file' field" if upload.nil? || !upload.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
descriptor = @workspace.upload(filename: upload[:filename], io: upload[:tempfile])
|
|
123
|
+
[201, { id: descriptor[:id], filename: descriptor[:filename], size: descriptor[:size] }]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Wraps rack.input with CappedInput so a mid-stream overflow raises
|
|
129
|
+
# before Rack::Multipart fully drains the body. On overflow we also
|
|
130
|
+
# unlink any tempfile the parser already created for the partial
|
|
131
|
+
# part, so no orphan upload is left under /tmp.
|
|
132
|
+
def parse_with_cap(env, limit)
|
|
133
|
+
original = env["rack.input"]
|
|
134
|
+
return Rack::Multipart.parse_multipart(env) || {} if original.nil?
|
|
135
|
+
|
|
136
|
+
capped = CappedInput.new(original, limit)
|
|
137
|
+
env["rack.input"] = capped
|
|
138
|
+
begin
|
|
139
|
+
Rack::Multipart.parse_multipart(env) || {}
|
|
140
|
+
rescue PayloadTooLargeError
|
|
141
|
+
cleanup_partial_tempfiles(env)
|
|
142
|
+
raise
|
|
143
|
+
ensure
|
|
144
|
+
env["rack.input"] = original
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Rack::Multipart streams each part to a Tempfile created via
|
|
149
|
+
# Rack::Multipart::Parser::TEMPFILE_FACTORY. When we abort mid-read
|
|
150
|
+
# those tempfiles remain on disk because the parser never returned
|
|
151
|
+
# the descriptor to us. We do not have a handle to them either —
|
|
152
|
+
# but rack.tempfiles (set by Rack::Multipart::Parser since 2.2) is
|
|
153
|
+
# the canonical collection. Unlink everything in it.
|
|
154
|
+
def cleanup_partial_tempfiles(env)
|
|
155
|
+
tempfiles = env["rack.tempfiles"]
|
|
156
|
+
return unless tempfiles.is_a?(Array)
|
|
157
|
+
|
|
158
|
+
tempfiles.each do |tf|
|
|
159
|
+
tf.close unless tf.closed?
|
|
160
|
+
File.unlink(tf.path) if tf.respond_to?(:path) && tf.path && File.exist?(tf.path)
|
|
161
|
+
rescue StandardError
|
|
162
|
+
# Best-effort cleanup; we already failed the request.
|
|
163
|
+
end
|
|
164
|
+
tempfiles.clear
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def max_upload_bytes
|
|
168
|
+
value = Rubino.configuration.dig("api", "max_upload_bytes")
|
|
169
|
+
value.is_a?(Integer) && value.positive? ? value : DEFAULT_MAX_UPLOAD_BYTES
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
# GET /v1/health — readiness probe.
|
|
7
|
+
# No auth required (allowlisted in Middleware::Auth::SKIP_PATHS).
|
|
8
|
+
#
|
|
9
|
+
# Pings the database and reports scheduler status alongside build info.
|
|
10
|
+
# Returns 503 if any critical dependency is degraded; never raises.
|
|
11
|
+
#
|
|
12
|
+
# @return [[Integer, Hash]] 200 when all deps are ok, 503 otherwise.
|
|
13
|
+
class HealthOperation
|
|
14
|
+
def self.call(_request)
|
|
15
|
+
new.call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
deps = { db: db_status, scheduler: scheduler_status }
|
|
20
|
+
status = deps.values.all? { |s| s[:status] == "ok" } ? 200 : 503
|
|
21
|
+
[status, {
|
|
22
|
+
status: status == 200 ? "ok" : "degraded",
|
|
23
|
+
version: Rubino::VERSION,
|
|
24
|
+
deps: deps
|
|
25
|
+
}]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def db_status
|
|
31
|
+
Rubino.database.db.test_connection
|
|
32
|
+
{ status: "ok" }
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
{ status: "down", error: e.class.name }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def scheduler_status
|
|
38
|
+
scheduler = ::Rubino::Jobs::Scheduler.instance
|
|
39
|
+
{ status: "ok", scheduled_jobs: scheduler.scheduled_count }
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
{ status: "down", error: e.class.name }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module Memory
|
|
7
|
+
# DELETE /v1/memory/:id
|
|
8
|
+
# Forgets one fact by id (id-prefix match, mirroring the CLI).
|
|
9
|
+
#
|
|
10
|
+
# @return [[Integer, Hash]] 204 No Content.
|
|
11
|
+
# @raise [Rubino::NotFoundError] when no fact matches the id.
|
|
12
|
+
class DeleteOperation
|
|
13
|
+
def self.call(request)
|
|
14
|
+
new.call(request)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Accepts an alternate backend for tests.
|
|
18
|
+
def initialize(backend: nil)
|
|
19
|
+
@backend = backend || ::Rubino::Memory::Backends.build
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request)
|
|
23
|
+
id = request.params.fetch("id")
|
|
24
|
+
raise NotFoundError.new("memory", id) unless @backend.delete(id)
|
|
25
|
+
|
|
26
|
+
Responses.no_content
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module API
|
|
5
|
+
module Operations
|
|
6
|
+
module Memory
|
|
7
|
+
# GET /v1/memory
|
|
8
|
+
# Lists stored facts from the active memory backend, newest first.
|
|
9
|
+
#
|
|
10
|
+
# `?q=` filters to facts whose content matches the query (case-insensitive
|
|
11
|
+
# substring). The match is applied at the API layer over the backend's
|
|
12
|
+
# admin #list so it works identically for every backend — the backends'
|
|
13
|
+
# own #retrieve is session/turn-relevance scoped and char-budget capped,
|
|
14
|
+
# which is the wrong shape for a flat admin listing.
|
|
15
|
+
#
|
|
16
|
+
# `?limit=` / `?offset=` paginate the (optionally filtered) result.
|
|
17
|
+
class IndexOperation
|
|
18
|
+
DEFAULT_LIMIT = 50
|
|
19
|
+
MAX_LIMIT = 200
|
|
20
|
+
# Window pulled from the backend before filter+paginate. Generous
|
|
21
|
+
# enough to page through a normal store; the backend keeps its own
|
|
22
|
+
# newest-first ordering.
|
|
23
|
+
WINDOW = 1000
|
|
24
|
+
|
|
25
|
+
def self.call(request)
|
|
26
|
+
new.call(request)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Accepts an alternate backend for tests.
|
|
30
|
+
def initialize(backend: nil)
|
|
31
|
+
@backend = backend || ::Rubino::Memory::Backends.build
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(request)
|
|
35
|
+
limit = clamp(request.query["limit"], DEFAULT_LIMIT, MAX_LIMIT)
|
|
36
|
+
offset = [request.query["offset"].to_i, 0].max
|
|
37
|
+
q = request.query["q"].to_s.strip
|
|
38
|
+
|
|
39
|
+
rows = @backend.list(limit: WINDOW)
|
|
40
|
+
rows = filter(rows, q) unless q.empty?
|
|
41
|
+
|
|
42
|
+
page = rows.slice(offset, limit) || []
|
|
43
|
+
[200, { memory: page.map { |row| Serializer.call(row) } }]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def filter(rows, q)
|
|
49
|
+
needle = q.downcase
|
|
50
|
+
rows.select { |row| row[:content].to_s.downcase.include?(needle) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clamp(raw, default, max)
|
|
54
|
+
n = raw.to_i
|
|
55
|
+
return default if n <= 0
|
|
56
|
+
|
|
57
|
+
[n, max].min
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Shared serializer for the memory surface. Backends differ slightly in
|
|
62
|
+
# the rows they return (the sqlite backend omits :updated_at), so every
|
|
63
|
+
# field is read defensively and absent ones serialize to null.
|
|
64
|
+
module Serializer
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
def call(row)
|
|
68
|
+
{
|
|
69
|
+
id: row[:id],
|
|
70
|
+
kind: row[:kind],
|
|
71
|
+
content: row[:content],
|
|
72
|
+
created_at: row[:created_at],
|
|
73
|
+
updated_at: row[:updated_at]
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|