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