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,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Run
|
|
5
|
+
# Synchronizes async HTTP decisions (approvals, clarifications) with the
|
|
6
|
+
# in-thread run loop. The run loop calls #await(id) and blocks; an HTTP
|
|
7
|
+
# endpoint calls #decide(id, value) to unblock it. One gate per run,
|
|
8
|
+
# owned by Executor and published in GateRegistry.
|
|
9
|
+
#
|
|
10
|
+
# Implementation: one +Queue+ per +id+, lazily created under a mutex.
|
|
11
|
+
# Each id must first be issued via #register before #decide will accept
|
|
12
|
+
# it — this prevents a stray POST with an arbitrary or replayed
|
|
13
|
+
# approval_id from unblocking an awaiting call. Decided ids are
|
|
14
|
+
# remembered with their resolved value so duplicate POSTs are
|
|
15
|
+
# idempotent (same decision returned, queue not pushed twice).
|
|
16
|
+
#
|
|
17
|
+
# Id namespace is shared per run: approval ids and clarify ids are
|
|
18
|
+
# both UUIDs minted by UI::API and routed through the same registry
|
|
19
|
+
# entry.
|
|
20
|
+
#
|
|
21
|
+
# Bounded wait (W1): #await never parks on a bare, effectively-infinite
|
|
22
|
+
# +queue.pop+. It loops over short, interruptible +pop(timeout:)+ ticks,
|
|
23
|
+
# re-checking the cancelled flag and an absolute deadline each tick, so:
|
|
24
|
+
# * an explicit #cancel! (run stop/teardown) wakes it within one tick,
|
|
25
|
+
# * an abandoned approval (client closed the tab, no decision ever) is
|
|
26
|
+
# released at the configured deadline instead of holding the worker
|
|
27
|
+
# thread for 24h and exhausting the server pool.
|
|
28
|
+
# On deadline expiry #await returns the EXPIRED sentinel (never an
|
|
29
|
+
# approve) and emits +approval.expired+; UI::API maps that to a safe DENY.
|
|
30
|
+
class ApprovalGate
|
|
31
|
+
# Default human-wait bound (seconds) used only when the caller passes
|
|
32
|
+
# none AND no config is reachable. The real default comes from
|
|
33
|
+
# approvals.wait_timeout_seconds. This is a SANE bound (15 minutes), not
|
|
34
|
+
# the old 24h: an unanswered approval must free its worker thread in
|
|
35
|
+
# minutes, not a day. nil = wait forever (opt-in, discouraged on servers).
|
|
36
|
+
DEFAULT_TIMEOUT = 900
|
|
37
|
+
|
|
38
|
+
# How long a single interruptible +pop(timeout:)+ tick blocks before the
|
|
39
|
+
# loop re-checks the cancelled flag / deadline. Small enough that a
|
|
40
|
+
# #cancel! is observed promptly even if its sentinel push raced; large
|
|
41
|
+
# enough not to spin. The sentinel push in #cancel! is the fast path;
|
|
42
|
+
# this tick is the safety net that bounds the worst-case wake latency.
|
|
43
|
+
WAKE_TICK = 0.25
|
|
44
|
+
private_constant :WAKE_TICK
|
|
45
|
+
|
|
46
|
+
# Pushed into a pending queue by #cancel! to wake a blocked #await; the
|
|
47
|
+
# awaiter sees it and raises Interrupted instead of returning a decision.
|
|
48
|
+
# A private object so it can never collide with a real decision value.
|
|
49
|
+
CANCELLED = Object.new.freeze
|
|
50
|
+
private_constant :CANCELLED
|
|
51
|
+
|
|
52
|
+
# Returned by #await when the human-wait deadline elapses with no
|
|
53
|
+
# decision. A distinct, non-approve sentinel: UI::API recognizes it and
|
|
54
|
+
# resolves the approval to a safe DENY (never an approve) and the
|
|
55
|
+
# clarification to nil — the abandoned-run safe default.
|
|
56
|
+
EXPIRED = Object.new.freeze
|
|
57
|
+
|
|
58
|
+
def initialize
|
|
59
|
+
@queues = {}
|
|
60
|
+
@issued = {} # id => recorder (or nil) — ids the gate will accept decisions for
|
|
61
|
+
@decided = {} # id => decision — first-write-wins, used for idempotency
|
|
62
|
+
@pending = {} # id => true while a thread is blocked in #await for it
|
|
63
|
+
@cancelled = false # set by #cancel!; makes future/in-flight awaits raise
|
|
64
|
+
@mutex = Mutex.new
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True when at least one #await call is currently blocked waiting for a
|
|
68
|
+
# decision. The SSE idle watchdog consults this (via GateRegistry) so it
|
|
69
|
+
# never reaps a run that is legitimately parked on a human answer.
|
|
70
|
+
def pending?
|
|
71
|
+
@mutex.synchronize { @pending.any? }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Marks +id+ as a valid target for a future #decide call, optionally
|
|
75
|
+
# binding a recorder used to emit +approval.decided+ once a decision
|
|
76
|
+
# lands. Must be called before #decide; otherwise #decide rejects
|
|
77
|
+
# the id as unknown. Idempotent: re-registering an id is a no-op.
|
|
78
|
+
def register(id, recorder: nil)
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@issued[id] = recorder unless @issued.key?(id)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Blocks until #decide is called for +id+, returns the decision value.
|
|
85
|
+
# Loops over short interruptible pops so a #cancel! or the deadline wakes
|
|
86
|
+
# it within one WAKE_TICK rather than parking on a bare pop.
|
|
87
|
+
#
|
|
88
|
+
# @param timeout [Numeric, :config, nil] seconds before giving up.
|
|
89
|
+
# :config (default) reads approvals.wait_timeout_seconds; nil waits
|
|
90
|
+
# forever (still interruptible by #cancel!).
|
|
91
|
+
# @return the decision value, or EXPIRED if the deadline elapses first.
|
|
92
|
+
# @raise [Rubino::Interrupted] if the gate is #cancel!-ed (run stopped)
|
|
93
|
+
# while this call is parked, so the worker thread unwinds at once.
|
|
94
|
+
def await(id, timeout: :config)
|
|
95
|
+
timeout = configured_timeout if timeout == :config
|
|
96
|
+
queue = queue_for(id)
|
|
97
|
+
# Lose the wake-up race safely: if #cancel! already fired, raise now
|
|
98
|
+
# rather than park on a queue nothing will ever push to.
|
|
99
|
+
raise Rubino::Interrupted if mark_pending(id)
|
|
100
|
+
|
|
101
|
+
deadline = timeout && (monotonic_now + timeout)
|
|
102
|
+
begin
|
|
103
|
+
loop do
|
|
104
|
+
decision = pop_tick(id, queue, deadline)
|
|
105
|
+
next if decision.equal?(:tick) # woke on a tick boundary; re-check
|
|
106
|
+
|
|
107
|
+
raise Rubino::Interrupted if decision.equal?(CANCELLED)
|
|
108
|
+
|
|
109
|
+
return decision # a real decision, or EXPIRED on deadline
|
|
110
|
+
end
|
|
111
|
+
ensure
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
@pending.delete(id)
|
|
114
|
+
@queues.delete(id)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Wakes every thread currently parked in #await (and any that park later)
|
|
120
|
+
# so they raise Interrupted and the worker thread unwinds. Called when a
|
|
121
|
+
# run is cancelled/stopped while parked on a human decision — without it
|
|
122
|
+
# the gate's pop blocks until the deadline and holds a Solid Queue worker
|
|
123
|
+
# thread for the whole window. One-shot, like CancelToken: once cancelled
|
|
124
|
+
# the gate stays cancelled.
|
|
125
|
+
def cancel!
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
@cancelled = true
|
|
128
|
+
@pending.each_key { |id| (@queues[id] ||= Queue.new) << CANCELLED }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Records a decision for +id+.
|
|
133
|
+
# @return [:ok, :duplicate, :unknown]
|
|
134
|
+
# * +:ok+ — first decision for a registered id; queue pushed.
|
|
135
|
+
# * +:duplicate+ — id was already decided (a real decision OR an
|
|
136
|
+
# auto-expiry); previous value preserved, queue NOT pushed again.
|
|
137
|
+
# * +:unknown+ — id was never #register-ed; nothing recorded.
|
|
138
|
+
# On +:ok+, emits an +approval.decided+ event through the recorder
|
|
139
|
+
# captured at #register time (when one was provided) so the SSE
|
|
140
|
+
# client can confirm receipt.
|
|
141
|
+
def decide(id, decision)
|
|
142
|
+
recorder = nil
|
|
143
|
+
status = @mutex.synchronize do
|
|
144
|
+
if !@issued.key?(id)
|
|
145
|
+
:unknown
|
|
146
|
+
elsif @decided.key?(id)
|
|
147
|
+
:duplicate
|
|
148
|
+
else
|
|
149
|
+
@decided[id] = decision
|
|
150
|
+
recorder = @issued[id]
|
|
151
|
+
:ok
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if status == :ok
|
|
156
|
+
queue_for(id) << decision
|
|
157
|
+
recorder&.emit("approval.decided", { approval_id: id, decision: decision })
|
|
158
|
+
end
|
|
159
|
+
status
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Decision previously resolved for +id+, or nil if none. May be the
|
|
163
|
+
# EXPIRED sentinel when the wait deadline elapsed before any #decide.
|
|
164
|
+
def decision_for(id)
|
|
165
|
+
@mutex.synchronize { @decided[id] }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# One interruptible wait step for +id+. Returns the popped value (a real
|
|
171
|
+
# decision or CANCELLED), +:tick+ when the per-tick pop simply timed out
|
|
172
|
+
# (loop should re-evaluate), or EXPIRED when the absolute deadline passed.
|
|
173
|
+
def pop_tick(id, queue, deadline)
|
|
174
|
+
wait = WAKE_TICK
|
|
175
|
+
if deadline
|
|
176
|
+
remaining = deadline - monotonic_now
|
|
177
|
+
return expire(id, queue) if remaining <= 0
|
|
178
|
+
|
|
179
|
+
wait = remaining if remaining < wait
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
value = queue.pop(timeout: wait)
|
|
183
|
+
return :tick if value.nil? # tick boundary: no decision yet
|
|
184
|
+
|
|
185
|
+
value
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Resolves +id+ to EXPIRED exactly once and announces it. Guarded by
|
|
189
|
+
# @decided so a #decide that landed in the same instant still wins if it
|
|
190
|
+
# got there first (then we return that real decision); otherwise records
|
|
191
|
+
# EXPIRED and emits +approval.expired+ via the recorder captured at
|
|
192
|
+
# #register so SSE clients observe the auto-deny.
|
|
193
|
+
def expire(id, queue)
|
|
194
|
+
recorder = nil
|
|
195
|
+
won = @mutex.synchronize do
|
|
196
|
+
if @decided.key?(id)
|
|
197
|
+
false
|
|
198
|
+
else
|
|
199
|
+
@decided[id] = EXPIRED
|
|
200
|
+
recorder = @issued[id]
|
|
201
|
+
true
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
# A real decision beat us to it — deliver it instead of EXPIRED.
|
|
205
|
+
return queue.pop(timeout: 0) || EXPIRED unless won
|
|
206
|
+
|
|
207
|
+
recorder&.emit("approval.expired", { approval_id: id })
|
|
208
|
+
EXPIRED
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Registers the awaiter as pending and reports whether the gate was
|
|
212
|
+
# already cancelled — done under the same lock as #cancel! so a cancel
|
|
213
|
+
# that fires concurrently either is seen here (return true → raise) or
|
|
214
|
+
# sees this id in @pending and pushes the sentinel. No lost wake-ups.
|
|
215
|
+
def mark_pending(id)
|
|
216
|
+
@mutex.synchronize do
|
|
217
|
+
@pending[id] = true
|
|
218
|
+
@cancelled
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The configured human-wait bound (approvals.wait_timeout_seconds).
|
|
223
|
+
# Falls back to DEFAULT_TIMEOUT when no configuration is reachable (unit
|
|
224
|
+
# tests that build a bare gate). nil means "wait forever".
|
|
225
|
+
def configured_timeout
|
|
226
|
+
cfg = Rubino.configuration if defined?(Rubino) && Rubino.respond_to?(:configuration)
|
|
227
|
+
return DEFAULT_TIMEOUT unless cfg.respond_to?(:approvals_wait_timeout)
|
|
228
|
+
|
|
229
|
+
cfg.approvals_wait_timeout
|
|
230
|
+
rescue StandardError
|
|
231
|
+
DEFAULT_TIMEOUT
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def monotonic_now
|
|
235
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def queue_for(id)
|
|
239
|
+
@mutex.synchronize { @queues[id] ||= Queue.new }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Rubino
|
|
8
|
+
module Run
|
|
9
|
+
# Fetches the URLs passed as `attachments` on a run and saves them
|
|
10
|
+
# under <workspace>/uploads/<run_id>/. The runner then tells the
|
|
11
|
+
# model "you have these local files" instead of forcing it to do
|
|
12
|
+
# tool calls (webfetch was crashing on binaries — see v0.2.5 fix —
|
|
13
|
+
# and even when it worked the model paid context for the bytes).
|
|
14
|
+
#
|
|
15
|
+
# SSRF guard: only URLs whose host appears in attachments.allowed_hosts
|
|
16
|
+
# (config) or ENV["ALLOWED_FILE_URL_HOSTS"] (comma-separated) are
|
|
17
|
+
# fetched. Empty config + empty env = block everything. The list is
|
|
18
|
+
# case-insensitive and matched exactly against the URI host (no port,
|
|
19
|
+
# no path, no subdomain magic) so an admin knows exactly what is
|
|
20
|
+
# allowed without re-reading regex semantics.
|
|
21
|
+
class AttachmentDownloader
|
|
22
|
+
MAX_BYTES_PER_FILE = 50 * 1024 * 1024 # 50 MB hard cap, matches uploads
|
|
23
|
+
HTTP_TIMEOUT = 30
|
|
24
|
+
|
|
25
|
+
# When an HTTP client is co-located on the same host as the agent,
|
|
26
|
+
# attachment URLs are loopback (http://localhost:3000/...).
|
|
27
|
+
# These are always allowed IN ADDITION to attachments.allowed_hosts so
|
|
28
|
+
# the common case works out of the box without opening the guard to
|
|
29
|
+
# arbitrary external hosts. SSRF risk is bounded: only the local host is
|
|
30
|
+
# reachable, which the agent could already talk to via the shell.
|
|
31
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
32
|
+
|
|
33
|
+
def initialize(workspace_root: nil, allowed_hosts: nil)
|
|
34
|
+
@workspace_root = workspace_root || Rubino::Workspace.primary_root
|
|
35
|
+
@allowed_hosts = normalize_hosts(allowed_hosts || default_allowed_hosts)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Array<String>] absolute paths of successfully saved files.
|
|
39
|
+
def fetch_all(run_id:, urls:)
|
|
40
|
+
list = Array(urls).reject { |u| u.to_s.strip.empty? }
|
|
41
|
+
return [] if list.empty?
|
|
42
|
+
|
|
43
|
+
dir = File.join(@workspace_root, "uploads", run_id.to_s)
|
|
44
|
+
FileUtils.mkdir_p(dir)
|
|
45
|
+
list.filter_map { |url| fetch_one(dir, url) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def fetch_one(dir, url)
|
|
51
|
+
uri = parse_uri(url)
|
|
52
|
+
return nil unless uri
|
|
53
|
+
|
|
54
|
+
unless host_allowed?(uri.host)
|
|
55
|
+
log_warn(url, "host #{uri.host.inspect} not in attachments.allowed_hosts")
|
|
56
|
+
return nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
filename = filename_for(uri)
|
|
60
|
+
path = File.join(dir, filename)
|
|
61
|
+
|
|
62
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
63
|
+
http.use_ssl = (uri.scheme == "https")
|
|
64
|
+
http.open_timeout = HTTP_TIMEOUT
|
|
65
|
+
http.read_timeout = HTTP_TIMEOUT
|
|
66
|
+
|
|
67
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
68
|
+
request["Accept"] = "*/*"
|
|
69
|
+
|
|
70
|
+
saved = nil
|
|
71
|
+
http.request(request) do |response|
|
|
72
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
73
|
+
log_warn(url, "HTTP #{response.code}")
|
|
74
|
+
return nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Prefer the server-supplied filename when available — beats
|
|
78
|
+
# whatever the URL path happened to encode.
|
|
79
|
+
if (real = filename_from_content_disposition(response["content-disposition"]))
|
|
80
|
+
path = File.join(dir, real)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
total = 0
|
|
84
|
+
File.open(path, "wb") do |f|
|
|
85
|
+
response.read_body do |chunk|
|
|
86
|
+
total += chunk.bytesize
|
|
87
|
+
if total > MAX_BYTES_PER_FILE
|
|
88
|
+
log_warn(url, "exceeded #{MAX_BYTES_PER_FILE} bytes, aborted")
|
|
89
|
+
f.close
|
|
90
|
+
File.delete(path) if File.exist?(path)
|
|
91
|
+
return nil
|
|
92
|
+
end
|
|
93
|
+
f.write(chunk)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
saved = path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
saved
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
log_warn(url, "#{e.class}: #{e.message}")
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def default_allowed_hosts
|
|
106
|
+
cfg = Array(Rubino.configuration&.dig("attachments", "allowed_hosts"))
|
|
107
|
+
env = ENV["ALLOWED_FILE_URL_HOSTS"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
108
|
+
cfg + env
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def normalize_hosts(list)
|
|
112
|
+
Array(list).map { |h| h.to_s.strip.downcase }.reject(&:empty?).to_set
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def host_allowed?(host)
|
|
116
|
+
# URI#host wraps IPv6 literals in brackets (`[::1]`); strip them so
|
|
117
|
+
# the comparison against LOOPBACK_HOSTS matches.
|
|
118
|
+
normalized = host.to_s.downcase.delete_prefix("[").delete_suffix("]")
|
|
119
|
+
return false if normalized.empty?
|
|
120
|
+
|
|
121
|
+
LOOPBACK_HOSTS.include?(normalized) || @allowed_hosts.include?(normalized)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_uri(url)
|
|
125
|
+
uri = URI.parse(url.to_s)
|
|
126
|
+
return nil unless %w[http https].include?(uri.scheme)
|
|
127
|
+
return nil if uri.host.to_s.empty?
|
|
128
|
+
|
|
129
|
+
uri
|
|
130
|
+
rescue URI::InvalidURIError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def filename_for(uri)
|
|
135
|
+
raw = uri.path.to_s
|
|
136
|
+
base = raw.empty? ? "attachment" : File.basename(raw)
|
|
137
|
+
sanitize_filename(base)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# `Content-Disposition: attachment; filename="foo.pdf"` or
|
|
141
|
+
# `filename*=UTF-8''foo%20bar.pdf`. We extract whichever is present.
|
|
142
|
+
def filename_from_content_disposition(header)
|
|
143
|
+
return nil if header.nil? || header.empty?
|
|
144
|
+
|
|
145
|
+
if (m = header.match(/filename\*=UTF-8''([^;]+)/i))
|
|
146
|
+
decoded = URI.decode_www_form_component(m[1])
|
|
147
|
+
return sanitize_filename(decoded)
|
|
148
|
+
end
|
|
149
|
+
if (m = header.match(/filename="?([^";]+)"?/i))
|
|
150
|
+
return sanitize_filename(m[1])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def sanitize_filename(name)
|
|
157
|
+
cleaned = name.to_s.tr("\\/", "_").gsub(/[^A-Za-z0-9._-]/, "_")
|
|
158
|
+
cleaned.empty? ? "attachment" : cleaned[-200..] || cleaned
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def log_warn(url, reason)
|
|
162
|
+
Rubino.logger&.warn(event: "attachment.fetch_failed", url: url, reason: reason)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Run
|
|
8
|
+
# Persists per-run events for SSE replay (Last-Event-ID) and audit.
|
|
9
|
+
#
|
|
10
|
+
# +seq+ is monotonic per +session_id+ (computed under a transaction as
|
|
11
|
+
# +max(seq) + 1+) so a single Session can stream across multiple Runs
|
|
12
|
+
# without seq collisions; SSE handlers send +seq+ as the event id and
|
|
13
|
+
# clients resume with +after_seq+.
|
|
14
|
+
#
|
|
15
|
+
# Reads order primarily by +seq+; +#for_run+ inherits that ordering.
|
|
16
|
+
# When two inserts land in the same wall-clock second, the
|
|
17
|
+
# +(created_at, rowid)+ tuple is the implicit tiebreaker for any
|
|
18
|
+
# consumer scanning by timestamp (Repository#last_for_session uses
|
|
19
|
+
# the same trick).
|
|
20
|
+
class EventStore
|
|
21
|
+
def initialize(db: nil)
|
|
22
|
+
@db = db || Rubino.database.db
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def append(session_id:, run_id:, type:, payload:)
|
|
26
|
+
@db.transaction do
|
|
27
|
+
next_seq = (@db[:events].where(session_id: session_id).max(:seq) || 0) + 1
|
|
28
|
+
row = {
|
|
29
|
+
id: SecureRandom.uuid,
|
|
30
|
+
session_id: session_id,
|
|
31
|
+
run_id: run_id,
|
|
32
|
+
type: type.to_s,
|
|
33
|
+
payload_json: JSON.generate(scrub_for_json(payload)),
|
|
34
|
+
seq: next_seq,
|
|
35
|
+
created_at: Time.now.utc.iso8601
|
|
36
|
+
}
|
|
37
|
+
@db[:events].insert(row)
|
|
38
|
+
row
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Recursively replaces invalid UTF-8 bytes so JSON.generate never raises
|
|
43
|
+
# JSON::GeneratorError on the event boundary. A tool that returns binary
|
|
44
|
+
# data (e.g. ReadTool on a misdetected PDF) would otherwise blow up here,
|
|
45
|
+
# propagate out of emit_finished, and kill the entire run — the model
|
|
46
|
+
# would never receive a tool error result and couldn't recover.
|
|
47
|
+
def scrub_for_json(value)
|
|
48
|
+
case value
|
|
49
|
+
when String
|
|
50
|
+
if value.encoding == Encoding::UTF_8
|
|
51
|
+
value.valid_encoding? ? value : value.scrub("?")
|
|
52
|
+
else
|
|
53
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub("?")
|
|
54
|
+
end
|
|
55
|
+
when Hash then value.transform_values { |v| scrub_for_json(v) }
|
|
56
|
+
when Array then value.map { |v| scrub_for_json(v) }
|
|
57
|
+
else value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param after_seq [Integer, nil] when given, returns only events with
|
|
62
|
+
# +seq > after_seq+ (used to honour SSE Last-Event-ID on reconnect).
|
|
63
|
+
def for_run(run_id, after_seq: nil)
|
|
64
|
+
ds = @db[:events].where(run_id: run_id).order(:seq)
|
|
65
|
+
ds = ds.where { seq > after_seq } if after_seq
|
|
66
|
+
ds.all
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def last_seq_for_session(session_id)
|
|
70
|
+
@db[:events].where(session_id: session_id).max(:seq) || 0
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|