rubik 0.1.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/ATTRIBUTION.md +139 -0
- data/CHANGELOG.md +236 -0
- data/LICENSE +21 -0
- data/README.md +269 -0
- data/bin/rubik +82 -0
- data/lib/rubik/adapter/base.rb +195 -0
- data/lib/rubik/adapter/c.rb +36 -0
- data/lib/rubik/adapter/crystal.rb +78 -0
- data/lib/rubik/adapter/generic.rb +86 -0
- data/lib/rubik/adapter/go.rb +36 -0
- data/lib/rubik/adapter/node.rb +146 -0
- data/lib/rubik/adapter/process.rb +78 -0
- data/lib/rubik/adapter/python.rb +30 -0
- data/lib/rubik/adapter/registry.rb +81 -0
- data/lib/rubik/adapter/ruby.rb +30 -0
- data/lib/rubik/adapter/rust.rb +36 -0
- data/lib/rubik/adapter/zig.rb +36 -0
- data/lib/rubik/adapter.rb +18 -0
- data/lib/rubik/ast/body_loader.rb +19 -0
- data/lib/rubik/ast/c_extractor.rb +114 -0
- data/lib/rubik/ast/cache_store.rb +191 -0
- data/lib/rubik/ast/extractor.rb +59 -0
- data/lib/rubik/ast/fallback_cleaner.rb +145 -0
- data/lib/rubik/ast/go_extractor.rb +148 -0
- data/lib/rubik/ast/graph.rb +272 -0
- data/lib/rubik/ast/python_extractor.rb +120 -0
- data/lib/rubik/ast/ruby_extractor.rb +260 -0
- data/lib/rubik/ast/rust_extractor.rb +127 -0
- data/lib/rubik/ast/symbol.rb +103 -0
- data/lib/rubik/ast/treesitter/base_walker.rb +109 -0
- data/lib/rubik/ast/treesitter/c_walker.rb +122 -0
- data/lib/rubik/ast/treesitter/go_walker.rb +120 -0
- data/lib/rubik/ast/treesitter/python_walker.rb +107 -0
- data/lib/rubik/ast/treesitter/rust_walker.rb +130 -0
- data/lib/rubik/ast/treesitter/typescript_walker.rb +182 -0
- data/lib/rubik/ast/treesitter/zig_walker.rb +109 -0
- data/lib/rubik/ast/treesitter_extractor.rb +93 -0
- data/lib/rubik/ast/typescript_extractor.rb +283 -0
- data/lib/rubik/ast/zig_extractor.rb +116 -0
- data/lib/rubik/ast.rb +13 -0
- data/lib/rubik/benchmark/runner.rb +224 -0
- data/lib/rubik/benchmark/synth_repo.rb +59 -0
- data/lib/rubik/benchmark.rb +9 -0
- data/lib/rubik/buffer/diff.rb +43 -0
- data/lib/rubik/buffer/manager.rb +427 -0
- data/lib/rubik/buffer/path_validator.rb +60 -0
- data/lib/rubik/buffer/snapshot.rb +99 -0
- data/lib/rubik/buffer.rb +10 -0
- data/lib/rubik/coeffects.rb +129 -0
- data/lib/rubik/concurrency/boundary.rb +74 -0
- data/lib/rubik/concurrency/dns_pool.rb +68 -0
- data/lib/rubik/concurrency/errors.rb +17 -0
- data/lib/rubik/concurrency/event_loop.rb +441 -0
- data/lib/rubik/concurrency/join.rb +55 -0
- data/lib/rubik/concurrency/process_waiter.rb +55 -0
- data/lib/rubik/concurrency/scheduler.rb +84 -0
- data/lib/rubik/concurrency/timers.rb +109 -0
- data/lib/rubik/concurrency.rb +132 -0
- data/lib/rubik/config/deep_ops.rb +80 -0
- data/lib/rubik/config/defaults.rb +248 -0
- data/lib/rubik/config/model_catalog.rb +93 -0
- data/lib/rubik/config/store.rb +155 -0
- data/lib/rubik/config.rb +216 -0
- data/lib/rubik/context/git_info.rb +84 -0
- data/lib/rubik/context/git_lister.rb +37 -0
- data/lib/rubik/context/index.rb +134 -0
- data/lib/rubik/context/repo_context_reader.rb +121 -0
- data/lib/rubik/context/scanner.rb +439 -0
- data/lib/rubik/context.rb +11 -0
- data/lib/rubik/diagnostics/collector.rb +200 -0
- data/lib/rubik/diagnostics/entry.rb +60 -0
- data/lib/rubik/diagnostics/parser.rb +107 -0
- data/lib/rubik/diagnostics.rb +10 -0
- data/lib/rubik/event_bus.rb +56 -0
- data/lib/rubik/kernel/dispatcher.rb +171 -0
- data/lib/rubik/kernel/dmesg.rb +116 -0
- data/lib/rubik/kernel/init.rb +177 -0
- data/lib/rubik/kernel/record.rb +85 -0
- data/lib/rubik/kernel/unit.rb +160 -0
- data/lib/rubik/llm/anthropic.rb +202 -0
- data/lib/rubik/llm/chunk_assembler.rb +285 -0
- data/lib/rubik/llm/client.rb +526 -0
- data/lib/rubik/llm/formatter.rb +450 -0
- data/lib/rubik/llm/message_sanitizer.rb +87 -0
- data/lib/rubik/llm/ollama.rb +167 -0
- data/lib/rubik/llm/openai.rb +21 -0
- data/lib/rubik/llm/openai_compatible.rb +216 -0
- data/lib/rubik/llm/provider.rb +125 -0
- data/lib/rubik/llm/registry.rb +336 -0
- data/lib/rubik/llm/router.rb +100 -0
- data/lib/rubik/llm.rb +18 -0
- data/lib/rubik/log.rb +38 -0
- data/lib/rubik/lsp/client.rb +475 -0
- data/lib/rubik/lsp/manager.rb +366 -0
- data/lib/rubik/lsp.rb +9 -0
- data/lib/rubik/mcp/boot_lifecycle.rb +114 -0
- data/lib/rubik/mcp/client.rb +169 -0
- data/lib/rubik/mcp/errors.rb +11 -0
- data/lib/rubik/mcp/manager.rb +619 -0
- data/lib/rubik/mcp/proxy_tool.rb +104 -0
- data/lib/rubik/mcp/transport/stdio.rb +255 -0
- data/lib/rubik/mcp.rb +12 -0
- data/lib/rubik/orchestrator/activity_log.rb +75 -0
- data/lib/rubik/orchestrator/auto_apply.rb +166 -0
- data/lib/rubik/orchestrator/budget_manager.rb +97 -0
- data/lib/rubik/orchestrator/continuation_manager.rb +161 -0
- data/lib/rubik/orchestrator/finish_message_selector.rb +55 -0
- data/lib/rubik/orchestrator/goal_ledger/drift.rb +71 -0
- data/lib/rubik/orchestrator/goal_ledger/ledger.rb +399 -0
- data/lib/rubik/orchestrator/goal_ledger/observation_projection.rb +179 -0
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +268 -0
- data/lib/rubik/orchestrator/goal_ledger/schema.rb +123 -0
- data/lib/rubik/orchestrator/goal_ledger/validator.rb +361 -0
- data/lib/rubik/orchestrator/goal_ledger/verifier.rb +129 -0
- data/lib/rubik/orchestrator/goal_ledger/verify_spec.rb +50 -0
- data/lib/rubik/orchestrator/goal_ledger.rb +65 -0
- data/lib/rubik/orchestrator/goal_state.rb +184 -0
- data/lib/rubik/orchestrator/goal_wait.rb +125 -0
- data/lib/rubik/orchestrator/loop.rb +388 -0
- data/lib/rubik/orchestrator/lsp_feedback.rb +55 -0
- data/lib/rubik/orchestrator/maestro_gate.rb +65 -0
- data/lib/rubik/orchestrator/provider_recovery.rb +90 -0
- data/lib/rubik/orchestrator/provider_resolver.rb +69 -0
- data/lib/rubik/orchestrator/shell_deps.rb +85 -0
- data/lib/rubik/orchestrator/step_executor.rb +328 -0
- data/lib/rubik/orchestrator/stream_manager.rb +118 -0
- data/lib/rubik/orchestrator/subagent/activation.rb +65 -0
- data/lib/rubik/orchestrator/subagent/budget.rb +108 -0
- data/lib/rubik/orchestrator/subagent/completion.rb +61 -0
- data/lib/rubik/orchestrator/subagent/concurrency_gate.rb +37 -0
- data/lib/rubik/orchestrator/subagent/conversation_id.rb +67 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +398 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +384 -0
- data/lib/rubik/orchestrator/subagent/lifecycle.rb +118 -0
- data/lib/rubik/orchestrator/subagent/messaging.rb +61 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +125 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +209 -0
- data/lib/rubik/orchestrator/subagent/questioner.rb +80 -0
- data/lib/rubik/orchestrator/subagent/reaper.rb +98 -0
- data/lib/rubik/orchestrator/subagent/resilience.rb +70 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +127 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +79 -0
- data/lib/rubik/orchestrator/subagent/step_runner.rb +240 -0
- data/lib/rubik/orchestrator/subagent/storage_id.rb +26 -0
- data/lib/rubik/orchestrator/subagent/supervisor.rb +327 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +141 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +199 -0
- data/lib/rubik/orchestrator/subagent_notice_inbox.rb +80 -0
- data/lib/rubik/orchestrator/tool_call_id.rb +47 -0
- data/lib/rubik/orchestrator/tool_circuit_breaker.rb +91 -0
- data/lib/rubik/orchestrator/tool_history.rb +39 -0
- data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +102 -0
- data/lib/rubik/orchestrator/turn_executor/user_channel.rb +172 -0
- data/lib/rubik/orchestrator/turn_executor.rb +376 -0
- data/lib/rubik/orchestrator.rb +11 -0
- data/lib/rubik/platform/filesystem.rb +64 -0
- data/lib/rubik/platform/io_drain.rb +53 -0
- data/lib/rubik/platform/path.rb +210 -0
- data/lib/rubik/platform/process.rb +38 -0
- data/lib/rubik/platform/sanitize.rb +157 -0
- data/lib/rubik/platform.rb +113 -0
- data/lib/rubik/plugin/dsl.rb +51 -0
- data/lib/rubik/plugin/manager.rb +247 -0
- data/lib/rubik/plugin/storage.rb +195 -0
- data/lib/rubik/plugin.rb +10 -0
- data/lib/rubik/prompt/context_builder.rb +377 -0
- data/lib/rubik/prompt/context_compactor.rb +326 -0
- data/lib/rubik/prompt/conversation_rows.rb +74 -0
- data/lib/rubik/prompt/doctrine.rb +160 -0
- data/lib/rubik/prompt/emergency_compactor.rb +81 -0
- data/lib/rubik/prompt/services.rb +60 -0
- data/lib/rubik/prompt/token_estimator.rb +63 -0
- data/lib/rubik/prompt/tool_teachings.rb +78 -0
- data/lib/rubik/prompt.rb +13 -0
- data/lib/rubik/repair/loop.rb +95 -0
- data/lib/rubik/repair.rb +8 -0
- data/lib/rubik/repl/ast_warmup.rb +129 -0
- data/lib/rubik/repl/command_context.rb +214 -0
- data/lib/rubik/repl/command_parser.rb +48 -0
- data/lib/rubik/repl/commands/agent.rb +30 -0
- data/lib/rubik/repl/commands/agent_context.rb +289 -0
- data/lib/rubik/repl/commands/agent_execution.rb +342 -0
- data/lib/rubik/repl/commands/agent_models.rb +212 -0
- data/lib/rubik/repl/commands/agent_providers.rb +232 -0
- data/lib/rubik/repl/commands/base.rb +55 -0
- data/lib/rubik/repl/commands/buffer.rb +124 -0
- data/lib/rubik/repl/commands/dmesg.rb +87 -0
- data/lib/rubik/repl/commands/lsp.rb +235 -0
- data/lib/rubik/repl/commands/mcp.rb +194 -0
- data/lib/rubik/repl/commands/plugins.rb +208 -0
- data/lib/rubik/repl/commands/pruner.rb +71 -0
- data/lib/rubik/repl/commands/session.rb +183 -0
- data/lib/rubik/repl/commands/skills.rb +111 -0
- data/lib/rubik/repl/commands/storage.rb +85 -0
- data/lib/rubik/repl/commands/subagents.rb +195 -0
- data/lib/rubik/repl/commands/system.rb +85 -0
- data/lib/rubik/repl/commands/tasks.rb +120 -0
- data/lib/rubik/repl/commands/workspace.rb +443 -0
- data/lib/rubik/repl/commands.rb +44 -0
- data/lib/rubik/repl/composition.rb +32 -0
- data/lib/rubik/repl/config_wiring.rb +142 -0
- data/lib/rubik/repl/confirmations.rb +56 -0
- data/lib/rubik/repl/hooks.rb +47 -0
- data/lib/rubik/repl/input_execution.rb +323 -0
- data/lib/rubik/repl/input_loop.rb +213 -0
- data/lib/rubik/repl/interrupt_handler.rb +217 -0
- data/lib/rubik/repl/lifecycle.rb +410 -0
- data/lib/rubik/repl/paste_support.rb +88 -0
- data/lib/rubik/repl/pending_notice_queue.rb +60 -0
- data/lib/rubik/repl/presentation.rb +132 -0
- data/lib/rubik/repl/pruner/brief.rb +101 -0
- data/lib/rubik/repl/pruner/gate.rb +279 -0
- data/lib/rubik/repl/pruner/harness.rb +236 -0
- data/lib/rubik/repl/pruner/rules.rb +316 -0
- data/lib/rubik/repl/pruner/service.rb +82 -0
- data/lib/rubik/repl/pruner.rb +39 -0
- data/lib/rubik/repl/service_catalog.rb +374 -0
- data/lib/rubik/repl/setup_factories.rb +66 -0
- data/lib/rubik/repl/shell.rb +231 -0
- data/lib/rubik/repl/step_formatter.rb +324 -0
- data/lib/rubik/repl/tee_io.rb +105 -0
- data/lib/rubik/repl/terminal_guard.rb +157 -0
- data/lib/rubik/repl/turn_recorder.rb +234 -0
- data/lib/rubik/repl.rb +14 -0
- data/lib/rubik/runtime/ast_service.rb +78 -0
- data/lib/rubik/runtime/context.rb +335 -0
- data/lib/rubik/runtime/diagnostic_engine.rb +188 -0
- data/lib/rubik/runtime/discovery.rb +342 -0
- data/lib/rubik/runtime/fiber.rb +142 -0
- data/lib/rubik/runtime/fiber_ordering.rb +52 -0
- data/lib/rubik/runtime/fiber_retry_gate.rb +84 -0
- data/lib/rubik/runtime/index_service.rb +58 -0
- data/lib/rubik/runtime/loop_service.rb +73 -0
- data/lib/rubik/runtime/manager.rb +241 -0
- data/lib/rubik/runtime/overlay_storage.rb +481 -0
- data/lib/rubik/runtime/service_definition.rb +54 -0
- data/lib/rubik/runtime/service_registration.rb +33 -0
- data/lib/rubik/runtime/task_manager.rb +302 -0
- data/lib/rubik/runtime.rb +19 -0
- data/lib/rubik/sandbox/driver/base.rb +30 -0
- data/lib/rubik/sandbox/driver/bubblewrap.rb +111 -0
- data/lib/rubik/sandbox/driver/docker.rb +113 -0
- data/lib/rubik/sandbox/driver/podman.rb +98 -0
- data/lib/rubik/sandbox/driver/process.rb +190 -0
- data/lib/rubik/sandbox/driver.rb +91 -0
- data/lib/rubik/sandbox/runner.rb +218 -0
- data/lib/rubik/sandbox/shell_resolver.rb +178 -0
- data/lib/rubik/sandbox.rb +8 -0
- data/lib/rubik/security/blocked_guidance.rb +187 -0
- data/lib/rubik/security/classifier/classifier.rb +440 -0
- data/lib/rubik/security/classifier/commands/agent-browser.yaml +9 -0
- data/lib/rubik/security/classifier/commands/apt.yaml +25 -0
- data/lib/rubik/security/classifier/commands/awk.yaml +10 -0
- data/lib/rubik/security/classifier/commands/basename.yaml +7 -0
- data/lib/rubik/security/classifier/commands/bash-classify.yaml +8 -0
- data/lib/rubik/security/classifier/commands/bash.yaml +20 -0
- data/lib/rubik/security/classifier/commands/black.yaml +12 -0
- data/lib/rubik/security/classifier/commands/brew.yaml +31 -0
- data/lib/rubik/security/classifier/commands/builtin.yaml +8 -0
- data/lib/rubik/security/classifier/commands/cargo.yaml +114 -0
- data/lib/rubik/security/classifier/commands/cat.yaml +7 -0
- data/lib/rubik/security/classifier/commands/chgrp.yaml +10 -0
- data/lib/rubik/security/classifier/commands/chmod.yaml +10 -0
- data/lib/rubik/security/classifier/commands/chown.yaml +10 -0
- data/lib/rubik/security/classifier/commands/column.yaml +7 -0
- data/lib/rubik/security/classifier/commands/comm.yaml +7 -0
- data/lib/rubik/security/classifier/commands/command.yaml +12 -0
- data/lib/rubik/security/classifier/commands/cp.yaml +7 -0
- data/lib/rubik/security/classifier/commands/crystal.yaml +63 -0
- data/lib/rubik/security/classifier/commands/curl.yaml +30 -0
- data/lib/rubik/security/classifier/commands/cut.yaml +7 -0
- data/lib/rubik/security/classifier/commands/date.yaml +10 -0
- data/lib/rubik/security/classifier/commands/df.yaml +7 -0
- data/lib/rubik/security/classifier/commands/diff.yaml +7 -0
- data/lib/rubik/security/classifier/commands/dig.yaml +7 -0
- data/lib/rubik/security/classifier/commands/dirname.yaml +7 -0
- data/lib/rubik/security/classifier/commands/docker.yaml +177 -0
- data/lib/rubik/security/classifier/commands/du.yaml +7 -0
- data/lib/rubik/security/classifier/commands/echo.yaml +7 -0
- data/lib/rubik/security/classifier/commands/env.yaml +15 -0
- data/lib/rubik/security/classifier/commands/eslint.yaml +12 -0
- data/lib/rubik/security/classifier/commands/eval.yaml +8 -0
- data/lib/rubik/security/classifier/commands/exec.yaml +12 -0
- data/lib/rubik/security/classifier/commands/expand.yaml +7 -0
- data/lib/rubik/security/classifier/commands/false.yaml +7 -0
- data/lib/rubik/security/classifier/commands/file.yaml +7 -0
- data/lib/rubik/security/classifier/commands/find.yaml +25 -0
- data/lib/rubik/security/classifier/commands/fmt.yaml +7 -0
- data/lib/rubik/security/classifier/commands/fold.yaml +7 -0
- data/lib/rubik/security/classifier/commands/free.yaml +7 -0
- data/lib/rubik/security/classifier/commands/gawk.yaml +10 -0
- data/lib/rubik/security/classifier/commands/gcloud.yaml +979 -0
- data/lib/rubik/security/classifier/commands/gh.yaml +615 -0
- data/lib/rubik/security/classifier/commands/git.yaml +524 -0
- data/lib/rubik/security/classifier/commands/glab-discussion.yaml +21 -0
- data/lib/rubik/security/classifier/commands/glab.yaml +807 -0
- data/lib/rubik/security/classifier/commands/go.yaml +44 -0
- data/lib/rubik/security/classifier/commands/gog.yaml +983 -0
- data/lib/rubik/security/classifier/commands/gradle.yaml +92 -0
- data/lib/rubik/security/classifier/commands/gradlew.yaml +92 -0
- data/lib/rubik/security/classifier/commands/grep.yaml +7 -0
- data/lib/rubik/security/classifier/commands/groups.yaml +7 -0
- data/lib/rubik/security/classifier/commands/gunzip.yaml +8 -0
- data/lib/rubik/security/classifier/commands/gws.yaml +211 -0
- data/lib/rubik/security/classifier/commands/gzip.yaml +7 -0
- data/lib/rubik/security/classifier/commands/hadolint.yaml +9 -0
- data/lib/rubik/security/classifier/commands/halt.yaml +8 -0
- data/lib/rubik/security/classifier/commands/head.yaml +7 -0
- data/lib/rubik/security/classifier/commands/helm.yaml +129 -0
- data/lib/rubik/security/classifier/commands/hostname.yaml +7 -0
- data/lib/rubik/security/classifier/commands/id.yaml +7 -0
- data/lib/rubik/security/classifier/commands/install.yaml +7 -0
- data/lib/rubik/security/classifier/commands/isort.yaml +14 -0
- data/lib/rubik/security/classifier/commands/java.yaml +35 -0
- data/lib/rubik/security/classifier/commands/javac.yaml +39 -0
- data/lib/rubik/security/classifier/commands/join.yaml +7 -0
- data/lib/rubik/security/classifier/commands/journalctl.yaml +14 -0
- data/lib/rubik/security/classifier/commands/jq.yaml +7 -0
- data/lib/rubik/security/classifier/commands/kill.yaml +7 -0
- data/lib/rubik/security/classifier/commands/killall.yaml +7 -0
- data/lib/rubik/security/classifier/commands/kubectl.yaml +211 -0
- data/lib/rubik/security/classifier/commands/less.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ln.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ls.yaml +7 -0
- data/lib/rubik/security/classifier/commands/make.yaml +21 -0
- data/lib/rubik/security/classifier/commands/md5sum.yaml +7 -0
- data/lib/rubik/security/classifier/commands/mise.yaml +579 -0
- data/lib/rubik/security/classifier/commands/mkdir.yaml +8 -0
- data/lib/rubik/security/classifier/commands/more.yaml +7 -0
- data/lib/rubik/security/classifier/commands/mv.yaml +7 -0
- data/lib/rubik/security/classifier/commands/mvn.yaml +83 -0
- data/lib/rubik/security/classifier/commands/mvnw.yaml +83 -0
- data/lib/rubik/security/classifier/commands/mypy.yaml +9 -0
- data/lib/rubik/security/classifier/commands/netstat.yaml +7 -0
- data/lib/rubik/security/classifier/commands/nice.yaml +11 -0
- data/lib/rubik/security/classifier/commands/nl.yaml +7 -0
- data/lib/rubik/security/classifier/commands/node.yaml +20 -0
- data/lib/rubik/security/classifier/commands/nohup.yaml +9 -0
- data/lib/rubik/security/classifier/commands/npm.yaml +71 -0
- data/lib/rubik/security/classifier/commands/npx.yaml +42 -0
- data/lib/rubik/security/classifier/commands/nslookup.yaml +7 -0
- data/lib/rubik/security/classifier/commands/paste.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ping.yaml +7 -0
- data/lib/rubik/security/classifier/commands/pip.yaml +33 -0
- data/lib/rubik/security/classifier/commands/pip3.yaml +33 -0
- data/lib/rubik/security/classifier/commands/pipx.yaml +33 -0
- data/lib/rubik/security/classifier/commands/pkill.yaml +7 -0
- data/lib/rubik/security/classifier/commands/pnpm.yaml +346 -0
- data/lib/rubik/security/classifier/commands/poetry.yaml +97 -0
- data/lib/rubik/security/classifier/commands/poweroff.yaml +8 -0
- data/lib/rubik/security/classifier/commands/prettier.yaml +13 -0
- data/lib/rubik/security/classifier/commands/printenv.yaml +7 -0
- data/lib/rubik/security/classifier/commands/printf.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ps.yaml +7 -0
- data/lib/rubik/security/classifier/commands/pup.yaml +882 -0
- data/lib/rubik/security/classifier/commands/pwd.yaml +7 -0
- data/lib/rubik/security/classifier/commands/pytest.yaml +7 -0
- data/lib/rubik/security/classifier/commands/python.yaml +9 -0
- data/lib/rubik/security/classifier/commands/python3.yaml +9 -0
- data/lib/rubik/security/classifier/commands/read.yaml +7 -0
- data/lib/rubik/security/classifier/commands/readlink.yaml +7 -0
- data/lib/rubik/security/classifier/commands/realpath.yaml +7 -0
- data/lib/rubik/security/classifier/commands/reboot.yaml +8 -0
- data/lib/rubik/security/classifier/commands/rev.yaml +7 -0
- data/lib/rubik/security/classifier/commands/rg.yaml +7 -0
- data/lib/rubik/security/classifier/commands/rm.yaml +8 -0
- data/lib/rubik/security/classifier/commands/rsync.yaml +12 -0
- data/lib/rubik/security/classifier/commands/rtk.yaml +140 -0
- data/lib/rubik/security/classifier/commands/ruff.yaml +29 -0
- data/lib/rubik/security/classifier/commands/rustfmt.yaml +14 -0
- data/lib/rubik/security/classifier/commands/scp.yaml +7 -0
- data/lib/rubik/security/classifier/commands/sed.yaml +14 -0
- data/lib/rubik/security/classifier/commands/seq.yaml +7 -0
- data/lib/rubik/security/classifier/commands/sh.yaml +14 -0
- data/lib/rubik/security/classifier/commands/sha1sum.yaml +7 -0
- data/lib/rubik/security/classifier/commands/sha256sum.yaml +7 -0
- data/lib/rubik/security/classifier/commands/shellcheck.yaml +10 -0
- data/lib/rubik/security/classifier/commands/shutdown.yaml +8 -0
- data/lib/rubik/security/classifier/commands/slack.yaml +113 -0
- data/lib/rubik/security/classifier/commands/sleep.yaml +8 -0
- data/lib/rubik/security/classifier/commands/snip.yaml +79 -0
- data/lib/rubik/security/classifier/commands/sort.yaml +10 -0
- data/lib/rubik/security/classifier/commands/ss.yaml +7 -0
- data/lib/rubik/security/classifier/commands/ssh.yaml +41 -0
- data/lib/rubik/security/classifier/commands/stat.yaml +7 -0
- data/lib/rubik/security/classifier/commands/stdbuf.yaml +15 -0
- data/lib/rubik/security/classifier/commands/strace.yaml +20 -0
- data/lib/rubik/security/classifier/commands/strings.yaml +11 -0
- data/lib/rubik/security/classifier/commands/sudo.yaml +24 -0
- data/lib/rubik/security/classifier/commands/systemctl.yaml +58 -0
- data/lib/rubik/security/classifier/commands/systemd-run.yaml +30 -0
- data/lib/rubik/security/classifier/commands/tac.yaml +7 -0
- data/lib/rubik/security/classifier/commands/tail.yaml +7 -0
- data/lib/rubik/security/classifier/commands/tar.yaml +25 -0
- data/lib/rubik/security/classifier/commands/tee.yaml +8 -0
- data/lib/rubik/security/classifier/commands/terraform.yaml +70 -0
- data/lib/rubik/security/classifier/commands/test.yaml +7 -0
- data/lib/rubik/security/classifier/commands/time.yaml +8 -0
- data/lib/rubik/security/classifier/commands/timeout.yaml +18 -0
- data/lib/rubik/security/classifier/commands/top.yaml +7 -0
- data/lib/rubik/security/classifier/commands/touch.yaml +8 -0
- data/lib/rubik/security/classifier/commands/tr.yaml +7 -0
- data/lib/rubik/security/classifier/commands/traceroute.yaml +7 -0
- data/lib/rubik/security/classifier/commands/true.yaml +7 -0
- data/lib/rubik/security/classifier/commands/tsc.yaml +16 -0
- data/lib/rubik/security/classifier/commands/type.yaml +7 -0
- data/lib/rubik/security/classifier/commands/uname.yaml +7 -0
- data/lib/rubik/security/classifier/commands/unexpand.yaml +7 -0
- data/lib/rubik/security/classifier/commands/uniq.yaml +7 -0
- data/lib/rubik/security/classifier/commands/unzip.yaml +10 -0
- data/lib/rubik/security/classifier/commands/uptime.yaml +7 -0
- data/lib/rubik/security/classifier/commands/uv.yaml +156 -0
- data/lib/rubik/security/classifier/commands/wait.yaml +11 -0
- data/lib/rubik/security/classifier/commands/wc.yaml +7 -0
- data/lib/rubik/security/classifier/commands/wget.yaml +7 -0
- data/lib/rubik/security/classifier/commands/whereis.yaml +7 -0
- data/lib/rubik/security/classifier/commands/which.yaml +7 -0
- data/lib/rubik/security/classifier/commands/whoami.yaml +7 -0
- data/lib/rubik/security/classifier/commands/xargs.yaml +26 -0
- data/lib/rubik/security/classifier/commands/xmllint.yaml +7 -0
- data/lib/rubik/security/classifier/commands/yarn.yaml +72 -0
- data/lib/rubik/security/classifier/commands/yes.yaml +7 -0
- data/lib/rubik/security/classifier/commands/yq.yaml +10 -0
- data/lib/rubik/security/classifier/commands/zip.yaml +8 -0
- data/lib/rubik/security/classifier/commands/zsh.yaml +14 -0
- data/lib/rubik/security/classifier/database.rb +216 -0
- data/lib/rubik/security/classifier/matcher/delegation.rb +190 -0
- data/lib/rubik/security/classifier/matcher/options.rb +245 -0
- data/lib/rubik/security/classifier/matcher/subcommands.rb +103 -0
- data/lib/rubik/security/classifier/matcher.rb +249 -0
- data/lib/rubik/security/classifier/models.rb +170 -0
- data/lib/rubik/security/classifier/parser.rb +23 -0
- data/lib/rubik/security/classifier/parsers/bash.rb +280 -0
- data/lib/rubik/security/classifier/parsers/bash_extractors.rb +221 -0
- data/lib/rubik/security/classifier/parsers/powershell.rb +185 -0
- data/lib/rubik/security/classifier/pwsh_rules.rb +224 -0
- data/lib/rubik/security/classifier.rb +25 -0
- data/lib/rubik/security/errors.rb +39 -0
- data/lib/rubik/security/guard.rb +954 -0
- data/lib/rubik/security/redactor.rb +89 -0
- data/lib/rubik/security.rb +11 -0
- data/lib/rubik/session/manager.rb +331 -0
- data/lib/rubik/session/recorder.rb +149 -0
- data/lib/rubik/session/replayer.rb +65 -0
- data/lib/rubik/session.rb +10 -0
- data/lib/rubik/skills/frontmatter.rb +41 -0
- data/lib/rubik/skills/manager.rb +223 -0
- data/lib/rubik/skills/registry.rb +64 -0
- data/lib/rubik/skills/skill.rb +33 -0
- data/lib/rubik/skills/storage.rb +77 -0
- data/lib/rubik/skills/validator.rb +116 -0
- data/lib/rubik/skills.rb +20 -0
- data/lib/rubik/storage/folding.rb +23 -0
- data/lib/rubik/storage/layout.rb +145 -0
- data/lib/rubik/storage/render.rb +97 -0
- data/lib/rubik/storage/service.rb +42 -0
- data/lib/rubik/storage/store.rb +514 -0
- data/lib/rubik/storage/transcript_indexer.rb +116 -0
- data/lib/rubik/storage/uri_reader.rb +168 -0
- data/lib/rubik/storage/vakanuvis/annal_writer.rb +92 -0
- data/lib/rubik/storage/vakanuvis/distill_gate.rb +323 -0
- data/lib/rubik/storage/vakanuvis/gate.rb +250 -0
- data/lib/rubik/storage/vakanuvis/harness.rb +421 -0
- data/lib/rubik/storage/vakanuvis/memory_steward.rb +163 -0
- data/lib/rubik/storage/vakanuvis/service.rb +69 -0
- data/lib/rubik/storage/vakanuvis/vocabulary.rb +99 -0
- data/lib/rubik/storage/vakanuvis/wiki_skeleton.rb +67 -0
- data/lib/rubik/storage/vakanuvis.rb +77 -0
- data/lib/rubik/storage.rb +29 -0
- data/lib/rubik/tools/base.rb +107 -0
- data/lib/rubik/tools/builtins/dmesg.rb +74 -0
- data/lib/rubik/tools/builtins/exec.rb +293 -0
- data/lib/rubik/tools/builtins/files.rb +354 -0
- data/lib/rubik/tools/builtins/goal_ledger.rb +89 -0
- data/lib/rubik/tools/builtins/grep.rb +281 -0
- data/lib/rubik/tools/builtins/interactive.rb +175 -0
- data/lib/rubik/tools/builtins/lifecycle.rb +359 -0
- data/lib/rubik/tools/builtins/lsp.rb +283 -0
- data/lib/rubik/tools/builtins/pruner.rb +88 -0
- data/lib/rubik/tools/builtins/runtime.rb +120 -0
- data/lib/rubik/tools/builtins/send_message.rb +109 -0
- data/lib/rubik/tools/builtins/show_file.rb +84 -0
- data/lib/rubik/tools/builtins/skill.rb +72 -0
- data/lib/rubik/tools/builtins/storage.rb +232 -0
- data/lib/rubik/tools/builtins/subagent.rb +356 -0
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +72 -0
- data/lib/rubik/tools/builtins/symbols.rb +209 -0
- data/lib/rubik/tools/builtins/synthesis.rb +314 -0
- data/lib/rubik/tools/builtins/task.rb +168 -0
- data/lib/rubik/tools/builtins/unsynthesize.rb +111 -0
- data/lib/rubik/tools/builtins/vakanuvis.rb +161 -0
- data/lib/rubik/tools/builtins.rb +30 -0
- data/lib/rubik/tools/catalog.rb +70 -0
- data/lib/rubik/tools/declaration.rb +90 -0
- data/lib/rubik/tools/diagnostic_reporter.rb +283 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +172 -0
- data/lib/rubik/tools/native_search.rb +180 -0
- data/lib/rubik/tools/output.rb +54 -0
- data/lib/rubik/tools/preflight_runner.rb +209 -0
- data/lib/rubik/tools/registry.rb +247 -0
- data/lib/rubik/tools/result.rb +71 -0
- data/lib/rubik/tools/services.rb +96 -0
- data/lib/rubik/tools/truncator.rb +78 -0
- data/lib/rubik/tools/uri_resolver.rb +195 -0
- data/lib/rubik/tools.rb +14 -0
- data/lib/rubik/ui/approvals.rb +52 -0
- data/lib/rubik/ui/banner.rb +43 -0
- data/lib/rubik/ui/capability.rb +122 -0
- data/lib/rubik/ui/dialog.rb +106 -0
- data/lib/rubik/ui/diff_viewer.rb +78 -0
- data/lib/rubik/ui/frame.rb +95 -0
- data/lib/rubik/ui/markdown_stream.rb +107 -0
- data/lib/rubik/ui/panel.rb +98 -0
- data/lib/rubik/ui/renderer.rb +206 -0
- data/lib/rubik/ui/spinner.rb +120 -0
- data/lib/rubik/ui/statusline.rb +134 -0
- data/lib/rubik/ui/theme.rb +118 -0
- data/lib/rubik/ui.rb +57 -0
- data/lib/rubik/uris.rb +101 -0
- data/lib/rubik/version.rb +5 -0
- data/lib/rubik.rb +65 -0
- metadata +770 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "../platform"
|
|
8
|
+
|
|
9
|
+
module Rubik
|
|
10
|
+
module LLM
|
|
11
|
+
class ClientError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# HTTP 429 (rate limited), carrying the parsed Retry-After hint when the
|
|
14
|
+
# server advertised one. Subclass of ClientError so every existing
|
|
15
|
+
# `rescue ClientError` site keeps working; the typed class lets callers
|
|
16
|
+
# distinguish "provider throttled us" from every other failure without
|
|
17
|
+
# parsing message strings.
|
|
18
|
+
class RateLimitError < ClientError
|
|
19
|
+
attr_reader :retry_after
|
|
20
|
+
|
|
21
|
+
def initialize(message, retry_after: nil)
|
|
22
|
+
super(message)
|
|
23
|
+
@retry_after = retry_after
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Buffers raw bytes from a stream and emits complete events.
|
|
28
|
+
# Default delimiter is the SSE blank line (\n\n); pass
|
|
29
|
+
# `terminator: "\n"` to parse NDJSON streams (one JSON object per line).
|
|
30
|
+
# One event is a Hash like:
|
|
31
|
+
# { type: "message", data: "json-string" }
|
|
32
|
+
# In SSE mode, multi-line `data:` fields are joined with \n. In NDJSON
|
|
33
|
+
# mode (`terminator: "\n"`), the `type` field is always "message" and
|
|
34
|
+
# the entire line is the `data`. Incomplete events are held in the
|
|
35
|
+
# internal buffer until the next feed.
|
|
36
|
+
class SSEParser
|
|
37
|
+
SSE_EVENT_SEP = /\r\n\r\n|\n\n|\r\n\n|\n\r\n|\r\r/
|
|
38
|
+
SSE_TERMINATOR = "\n\n"
|
|
39
|
+
NDJSON_TERMINATOR = "\n"
|
|
40
|
+
|
|
41
|
+
def initialize(terminator: SSE_TERMINATOR)
|
|
42
|
+
@terminator = terminator
|
|
43
|
+
@sse_mode = @terminator == SSE_TERMINATOR || @terminator == SSE_EVENT_SEP
|
|
44
|
+
@buffer = String.new(encoding: Encoding::UTF_8)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Feed raw bytes; returns an array of complete events parsed from this
|
|
48
|
+
# chunk. Partial events stay in the buffer.
|
|
49
|
+
def feed(chunk_bytes)
|
|
50
|
+
incoming = chunk_bytes.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
51
|
+
incoming = incoming.scrub unless incoming.valid_encoding?
|
|
52
|
+
@buffer << incoming
|
|
53
|
+
events = []
|
|
54
|
+
|
|
55
|
+
if @sse_mode
|
|
56
|
+
while (m = @buffer.match(SSE_EVENT_SEP))
|
|
57
|
+
# Chunk boundary \r buffering: if match ends on \r at the very end of @buffer,
|
|
58
|
+
# wait for next feed to see if \n follows (e.g. \r\r could become \r\r\n).
|
|
59
|
+
break if m.end(0) == @buffer.bytesize && @buffer.end_with?("\r")
|
|
60
|
+
|
|
61
|
+
event_block = @buffer[0...m.begin(0)]
|
|
62
|
+
@buffer = @buffer[m.end(0)..] || String.new(encoding: Encoding::UTF_8)
|
|
63
|
+
parsed = parse_event(event_block)
|
|
64
|
+
events << parsed if parsed
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
while (idx = @buffer.index(@terminator))
|
|
68
|
+
event_block = @buffer[0...idx]
|
|
69
|
+
@buffer = @buffer[(idx + @terminator.length)..] || String.new(encoding: Encoding::UTF_8)
|
|
70
|
+
parsed = parse_event(event_block)
|
|
71
|
+
events << parsed if parsed
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
events
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Flush any trailing event (no trailing terminator) and reset.
|
|
79
|
+
def flush
|
|
80
|
+
return [] if @buffer.empty?
|
|
81
|
+
|
|
82
|
+
events = []
|
|
83
|
+
if @sse_mode
|
|
84
|
+
while (m = @buffer.match(SSE_EVENT_SEP))
|
|
85
|
+
event_block = @buffer[0...m.begin(0)]
|
|
86
|
+
@buffer = @buffer[m.end(0)..] || String.new(encoding: Encoding::UTF_8)
|
|
87
|
+
parsed = parse_event(event_block)
|
|
88
|
+
events << parsed if parsed
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
while (idx = @buffer.index(@terminator))
|
|
92
|
+
event_block = @buffer[0...idx]
|
|
93
|
+
@buffer = @buffer[(idx + @terminator.length)..] || String.new(encoding: Encoding::UTF_8)
|
|
94
|
+
parsed = parse_event(event_block)
|
|
95
|
+
events << parsed if parsed
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
unless @buffer.empty?
|
|
100
|
+
remaining = @buffer.dup
|
|
101
|
+
@buffer = String.new(encoding: Encoding::UTF_8)
|
|
102
|
+
parsed = parse_event(remaining)
|
|
103
|
+
events << parsed if parsed
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
events
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def parse_event(block)
|
|
112
|
+
return nil if block.nil?
|
|
113
|
+
return { type: "message", data: block.to_s.chomp } unless @sse_mode
|
|
114
|
+
|
|
115
|
+
lines = block.to_s.split(/\r\n|\r|\n/)
|
|
116
|
+
return nil if lines.empty?
|
|
117
|
+
|
|
118
|
+
event_type = "message"
|
|
119
|
+
data_lines = []
|
|
120
|
+
has_field = false
|
|
121
|
+
|
|
122
|
+
lines.each do |line|
|
|
123
|
+
next if line.empty?
|
|
124
|
+
next if line.start_with?(":")
|
|
125
|
+
|
|
126
|
+
if (m = line.match(/\Aevent:\s*(.+)\z/))
|
|
127
|
+
event_type = m[1]
|
|
128
|
+
has_field = true
|
|
129
|
+
elsif (m = line.match(/\Adata:\s?(.*)\z/))
|
|
130
|
+
data_lines << m[1]
|
|
131
|
+
has_field = true
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return nil unless has_field
|
|
136
|
+
|
|
137
|
+
{ type: event_type, data: data_lines.join("\n") }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class HTTPClient
|
|
142
|
+
# Errors that are safe to retry with an idempotent bounded backoff:
|
|
143
|
+
# connect-phase failures and transport resets. Net::ReadTimeout is
|
|
144
|
+
# deliberately NOT here: a read timeout on a non-streaming LLM call
|
|
145
|
+
# means the server is still generating, and re-sending the full payload
|
|
146
|
+
# would silently duplicate work (the original 2-minute freeze).
|
|
147
|
+
RETRYABLE_ERRORS = [
|
|
148
|
+
Net::OpenTimeout,
|
|
149
|
+
Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED,
|
|
150
|
+
Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
|
|
151
|
+
SocketError,
|
|
152
|
+
EOFError
|
|
153
|
+
].freeze
|
|
154
|
+
|
|
155
|
+
# Shared hosted-model concurrency limits (429) arrive in bursts lasting
|
|
156
|
+
# seconds to minutes and advertise their window via `Retry-After`. The
|
|
157
|
+
# generic budget (max_retries: 2, ~1.2s backoff) gives up mid-burst and
|
|
158
|
+
# loses the turn; rate limiting gets its own, larger budget and honors
|
|
159
|
+
# the server's hint, capped so a pathological header can't stall an
|
|
160
|
+
# interactive agent turn indefinitely.
|
|
161
|
+
RATE_LIMIT_MAX_DELAY_S = 60
|
|
162
|
+
|
|
163
|
+
# Read a response header defensively: test doubles and exotic response
|
|
164
|
+
# objects may not implement the Net::HTTPHeader interface.
|
|
165
|
+
def self.header_value(res, name)
|
|
166
|
+
res.respond_to?(:key?) && res.key?(name) ? res[name] : nil
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Retry-After may be delay-seconds or an HTTP-date; anything else is
|
|
172
|
+
# unparseable -> nil (caller falls back to exponential backoff).
|
|
173
|
+
def self.parse_retry_after(value)
|
|
174
|
+
return nil if value.nil?
|
|
175
|
+
|
|
176
|
+
s = value.to_s.strip
|
|
177
|
+
return s.to_f if s.match?(/\A\d+(\.\d+)?\z/)
|
|
178
|
+
|
|
179
|
+
t = Time.httpdate(s)
|
|
180
|
+
d = (t - Time.now).ceil
|
|
181
|
+
d.positive? ? d : 0
|
|
182
|
+
rescue StandardError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Delay before re-sending after a 429: the server's Retry-After hint
|
|
187
|
+
# when present (honored exactly — the server knows its own window);
|
|
188
|
+
# otherwise exponential backoff with ±20% jitter to de-synchronize
|
|
189
|
+
# concurrent clients hammering the same endpoint. Both capped.
|
|
190
|
+
def self.retry_delay_after(res, retries, backoff_base)
|
|
191
|
+
hint = parse_retry_after(header_value(res, "Retry-After"))
|
|
192
|
+
delay = hint
|
|
193
|
+
unless delay
|
|
194
|
+
delay = backoff_base * (2**(retries - 1))
|
|
195
|
+
delay *= (0.8 + (0.4 * rand))
|
|
196
|
+
end
|
|
197
|
+
delay.clamp(0, RATE_LIMIT_MAX_DELAY_S)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# `retries` is nil on paths that never entered the retry loop (streaming)
|
|
201
|
+
# — the message then omits the attempt count instead of falsely claiming
|
|
202
|
+
# "gave up after 0 retries" (fresh-eye R5).
|
|
203
|
+
#
|
|
204
|
+
# `body` overrides `res.body` for the streaming path: inside Net::HTTP's
|
|
205
|
+
# block form (`http.request { |response| ... }`) the accessor returns an
|
|
206
|
+
# unmaterialized Net::ReadAdapter sink, not the transferred bytes, so the
|
|
207
|
+
# caller must pass the chunks it drained (fresh-eye R7 — a live 429 paste
|
|
208
|
+
# showed "#<Net::ReadAdapter:0x...>" instead of the provider's error JSON).
|
|
209
|
+
def self.rate_limit_error(res, retries, body = nil)
|
|
210
|
+
hint = parse_retry_after(header_value(res, "Retry-After"))
|
|
211
|
+
detail = hint ? "server hinted #{hint}s" : "no Retry-After hint"
|
|
212
|
+
budget = retries ? " (gave up after #{retries} rate-limit retries)" : ""
|
|
213
|
+
RateLimitError.new(
|
|
214
|
+
"HTTP 429 rate limited#{budget}, #{detail}: #{Rubik::Platform.safe_utf8(body || res.body)}",
|
|
215
|
+
retry_after: hint
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
|
|
220
|
+
max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
|
|
221
|
+
on_retry: nil, http_factory: nil)
|
|
222
|
+
begin
|
|
223
|
+
uri = URI.parse(url_str.to_s)
|
|
224
|
+
rescue URI::InvalidURIError => e
|
|
225
|
+
raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
229
|
+
raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https:// (e.g. https://api.openai.com/v1/chat/completions)"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
233
|
+
retries = 0
|
|
234
|
+
rate_limit_retries = 0
|
|
235
|
+
|
|
236
|
+
loop do
|
|
237
|
+
res =
|
|
238
|
+
if http_factory
|
|
239
|
+
http = http_factory.call(uri)
|
|
240
|
+
req = build_post_request(uri, headers, payload)
|
|
241
|
+
http.request(req)
|
|
242
|
+
else
|
|
243
|
+
with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
|
|
244
|
+
req = build_post_request(uri, headers, payload)
|
|
245
|
+
http.request(req)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
status = res.code.to_i
|
|
250
|
+
|
|
251
|
+
if status == 429
|
|
252
|
+
# Rate limiting is transitory and self-healing — it gets its own
|
|
253
|
+
# larger budget (max_429_retries), a Retry-After-aware delay, and
|
|
254
|
+
# a typed error when exhausted so callers can report "provider
|
|
255
|
+
# throttled" distinctly. The generic 5xx budget is untouched, so
|
|
256
|
+
# one 429 burst cannot exhaust the transport-failure budget.
|
|
257
|
+
if rate_limit_retries < max_429_retries
|
|
258
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
259
|
+
delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
|
|
260
|
+
if elapsed + delay <= max_wall_clock
|
|
261
|
+
rate_limit_retries += 1
|
|
262
|
+
on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
|
|
263
|
+
sleep(delay)
|
|
264
|
+
next
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
raise rate_limit_error(res, rate_limit_retries)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
if (status >= 500) && (retries < max_retries)
|
|
271
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
272
|
+
delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
|
|
273
|
+
if elapsed + delay <= max_wall_clock
|
|
274
|
+
retries += 1
|
|
275
|
+
on_retry&.call(retries, max_retries, "HTTP #{status}")
|
|
276
|
+
sleep(delay)
|
|
277
|
+
next
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
unless status >= 200 && status < 300
|
|
282
|
+
# A body without a charset arrives tagged ASCII-8BIT; embedding it
|
|
283
|
+
# raw makes every ClientError message binary and the UTF-8 output
|
|
284
|
+
# layer raises inside the REPL's rescue clause (masking the real
|
|
285
|
+
# HTTP error as Encoding::CompatibilityError).
|
|
286
|
+
raise ClientError, "HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
begin
|
|
290
|
+
return JSON.parse(res.body)
|
|
291
|
+
rescue JSON::ParserError => e
|
|
292
|
+
raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
|
|
293
|
+
end
|
|
294
|
+
rescue ClientError
|
|
295
|
+
raise
|
|
296
|
+
rescue Net::ReadTimeout
|
|
297
|
+
# Server accepted the request and is still working. NEVER retry:
|
|
298
|
+
raise ClientError, "read timeout after #{timeout}s calling #{uri.host} (model/server still working? request not retried)"
|
|
299
|
+
rescue Interrupt
|
|
300
|
+
raise # rubocop:disable Lint/DuplicateBranch -- Ctrl+C yayilimi bilinçli olarak ayri tutuldu, even mid-backoff
|
|
301
|
+
rescue *RETRYABLE_ERRORS => e
|
|
302
|
+
if retries < max_retries
|
|
303
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
304
|
+
delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
|
|
305
|
+
if elapsed + delay <= max_wall_clock
|
|
306
|
+
retries += 1
|
|
307
|
+
on_retry&.call(retries, max_retries, e.class.name)
|
|
308
|
+
sleep(delay)
|
|
309
|
+
next
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
raise ClientError, "Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})"
|
|
313
|
+
rescue StandardError => e
|
|
314
|
+
raise ClientError, "Network error calling #{uri.host}: #{e.class} #{e.message}"
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Builds the real Net::HTTP transport. open_timeout is bounded LOW and
|
|
319
|
+
# independent of the (long) read timeout: Net::HTTP arms a Timeout thread
|
|
320
|
+
# around TCP connect on Ruby 3.3; a short arming window shrinks the chance
|
|
321
|
+
# that a stale Net::OpenTimeout is injected into unrelated main-thread
|
|
322
|
+
# code after an Interrupt escapes the connect region.
|
|
323
|
+
def self.default_http(uri, timeout:, open_timeout:)
|
|
324
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
325
|
+
http.use_ssl = (uri.scheme == "https")
|
|
326
|
+
http.open_timeout = open_timeout
|
|
327
|
+
http.read_timeout = timeout
|
|
328
|
+
http
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# ---- Persistent connection pool -------------------------------------
|
|
332
|
+
#
|
|
333
|
+
# Opening a brand-new TCP(+TLS) connection per LLM round-trip costs
|
|
334
|
+
# ~100-300 ms each, and an agent turn issues many round-trips. Sessions
|
|
335
|
+
# here persist per (host, port, scheme); a mutex per key serializes
|
|
336
|
+
# concurrent use because a single Net::HTTP session is not thread-safe
|
|
337
|
+
# (same-endpoint calls wait for the current response to finish).
|
|
338
|
+
# Ruby's non-reentrant Mutex would deadlock on same-thread re-entry
|
|
339
|
+
# (nested provider call, Enumerator#next fibers) — those transparently
|
|
340
|
+
# fall back to a private single-use connection. Transport failures and
|
|
341
|
+
# interrupts drop the session so the next attempt dials fresh.
|
|
342
|
+
CONNECTION_IDLE_TIMEOUT = 300
|
|
343
|
+
|
|
344
|
+
# After any of these the socket's integrity is unknown (or the stream
|
|
345
|
+
# was cut mid-read by Ctrl+C): never hand the session back to the pool.
|
|
346
|
+
INTEGRITY_LOST_ERRORS = [
|
|
347
|
+
*RETRYABLE_ERRORS, Net::ReadTimeout, IOError,
|
|
348
|
+
OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Interrupt
|
|
349
|
+
].freeze
|
|
350
|
+
|
|
351
|
+
@pool_mutex = Mutex.new
|
|
352
|
+
@pool = {}
|
|
353
|
+
@pool_locks = {}
|
|
354
|
+
@pool_locks_guard = Mutex.new
|
|
355
|
+
|
|
356
|
+
def self.with_pooled_connection(uri, timeout:, open_timeout:)
|
|
357
|
+
key = [uri.host, uri.port, uri.scheme == "https"]
|
|
358
|
+
lock = @pool_locks_guard.synchronize { @pool_locks[key] ||= Mutex.new }
|
|
359
|
+
|
|
360
|
+
begin
|
|
361
|
+
lock.synchronize do
|
|
362
|
+
http = @pool_mutex.synchronize { @pool[key] }
|
|
363
|
+
if http&.started?
|
|
364
|
+
http.read_timeout = timeout
|
|
365
|
+
http.open_timeout = open_timeout
|
|
366
|
+
else
|
|
367
|
+
http = default_http(uri, timeout: timeout, open_timeout: open_timeout)
|
|
368
|
+
http.keep_alive_timeout = CONNECTION_IDLE_TIMEOUT
|
|
369
|
+
http.start
|
|
370
|
+
@pool_mutex.synchronize { @pool[key] = http }
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
begin
|
|
374
|
+
yield http
|
|
375
|
+
rescue *INTEGRITY_LOST_ERRORS
|
|
376
|
+
drop_pooled(key, http)
|
|
377
|
+
raise
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
rescue ThreadError
|
|
381
|
+
# Re-entrant call on the same thread (nested LLM call from inside a
|
|
382
|
+
# streaming callback, or a second Enumerator#next fiber) — the
|
|
383
|
+
# per-key mutex cannot be taken. Serve this call on a private,
|
|
384
|
+
# single-use connection instead of deadlocking.
|
|
385
|
+
http = default_http(uri, timeout: timeout, open_timeout: open_timeout)
|
|
386
|
+
http.start
|
|
387
|
+
begin
|
|
388
|
+
yield http
|
|
389
|
+
ensure
|
|
390
|
+
http.finish if http.started?
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def self.drop_pooled(key, http)
|
|
396
|
+
@pool_mutex.synchronize { @pool.delete(key) if @pool[key].equal?(http) }
|
|
397
|
+
http.finish if http&.started?
|
|
398
|
+
rescue StandardError
|
|
399
|
+
nil
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def self.build_post_request(uri, headers, payload, accept: nil)
|
|
403
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
404
|
+
headers.each { |k, v| req[k] = v }
|
|
405
|
+
req["Content-Type"] = "application/json"
|
|
406
|
+
req["Accept"] = accept if accept
|
|
407
|
+
req["User-Agent"] ||= "Rubik/1.0 (Windows; x64; Agentic CLI)"
|
|
408
|
+
req.body = Rubik::Platform.safe_json_dump(payload)
|
|
409
|
+
req
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Streaming variant of `post_json`. Yields each SSE event (as a Hash
|
|
413
|
+
# `{ type:, data: }`) to the block. Does NOT retry once any part of the
|
|
414
|
+
# response has surfaced — partial chunks are dangerous in a stream.
|
|
415
|
+
# Exception: a transport failure before ANY byte arrived (a stale
|
|
416
|
+
# pooled socket hit at connect/request time) is retried once on a
|
|
417
|
+
# fresh connection, since the caller has seen nothing yet.
|
|
418
|
+
#
|
|
419
|
+
# The transport uses `Net::HTTP`'s block form of `#request`, which
|
|
420
|
+
# gives access to streaming response bytes. We feed them to an
|
|
421
|
+
# `SSEParser` and yield complete events as they emerge.
|
|
422
|
+
#
|
|
423
|
+
# `open_timeout` bounds only the TCP/TLS connect phase and is therefore
|
|
424
|
+
# deliberately LOW and independent of the (long) read timeout — same
|
|
425
|
+
# rationale as in `default_http`: waiting out a 60 s open window after
|
|
426
|
+
# e.g. a DNS failure is exactly the multi-second freeze users see as
|
|
427
|
+
# "the app hangs".
|
|
428
|
+
#
|
|
429
|
+
# If the response is not an SSE event stream (no events arrive before
|
|
430
|
+
# EOF), the full body is still yielded as a single `{ type: "message",
|
|
431
|
+
# data: <body> }` event, so NDJSON-style consumers can read the
|
|
432
|
+
# last "chunk" via `flush`.
|
|
433
|
+
def self.post_stream_json(url_str, headers: {}, payload: {}, timeout: 120, open_timeout: 15,
|
|
434
|
+
idle_timeout: nil,
|
|
435
|
+
terminator: SSEParser::SSE_TERMINATOR, http_factory: nil, &)
|
|
436
|
+
begin
|
|
437
|
+
uri = URI.parse(url_str.to_s)
|
|
438
|
+
rescue URI::InvalidURIError => e
|
|
439
|
+
raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https://" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
443
|
+
|
|
444
|
+
effective_timeout = idle_timeout || timeout
|
|
445
|
+
state = { surfaced: false }
|
|
446
|
+
req = build_post_request(uri, headers, payload, accept: "text/event-stream")
|
|
447
|
+
attempts = 0
|
|
448
|
+
|
|
449
|
+
begin
|
|
450
|
+
attempts += 1
|
|
451
|
+
if http_factory
|
|
452
|
+
http = http_factory.call(uri)
|
|
453
|
+
http.read_timeout = effective_timeout if http.respond_to?(:read_timeout=)
|
|
454
|
+
http.open_timeout = open_timeout if http.respond_to?(:open_timeout=)
|
|
455
|
+
stream_attempt(http, uri, req, terminator: terminator, state: state, &)
|
|
456
|
+
else
|
|
457
|
+
with_pooled_connection(uri, timeout: effective_timeout, open_timeout: open_timeout) do |http|
|
|
458
|
+
stream_attempt(http, uri, req, terminator: terminator, state: state, &)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
rescue ClientError
|
|
462
|
+
raise
|
|
463
|
+
rescue *RETRYABLE_ERRORS, IOError => e
|
|
464
|
+
# Transparent fresh-socket retry ONLY while literally nothing has
|
|
465
|
+
# reached the caller: no headers, no events. A stale pooled idle
|
|
466
|
+
# socket manifests exactly like this.
|
|
467
|
+
clean_retry = !state[:surfaced] && attempts < 2
|
|
468
|
+
retry if clean_retry
|
|
469
|
+
raise ClientError, "Network error during stream from #{uri.host}: #{e.message}"
|
|
470
|
+
rescue StandardError => e
|
|
471
|
+
# Everything else is wrapped once without retrying. That includes
|
|
472
|
+
# Net::ReadTimeout — same policy as post_json: the server may
|
|
473
|
+
# already be generating, and re-sending would duplicate work while
|
|
474
|
+
# doubling the user-visible freeze — and TLS handshake failures
|
|
475
|
+
# raised by http.start inside the pool.
|
|
476
|
+
raise ClientError, "Network error during stream from #{uri.host}: #{e.message}"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
:ok
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# A single streaming request over an established connection. Sets
|
|
483
|
+
# `state[:surfaced]` once response headers arrive; from that point on
|
|
484
|
+
# the caller may already have consumed events.
|
|
485
|
+
def self.stream_attempt(http, uri, req, terminator:, state:, &callback)
|
|
486
|
+
parser = SSEParser.new(terminator: terminator)
|
|
487
|
+
|
|
488
|
+
begin
|
|
489
|
+
http.request(req) do |response|
|
|
490
|
+
state[:surfaced] = true
|
|
491
|
+
status_code = response.code.to_i
|
|
492
|
+
|
|
493
|
+
unless status_code >= 200 && status_code < 300
|
|
494
|
+
# Drain the body so the connection can be released, then raise.
|
|
495
|
+
body_chunks = []
|
|
496
|
+
response.read_body { |chunk| body_chunks << chunk }
|
|
497
|
+
# Streaming never retries a 429 (once headers surface, events
|
|
498
|
+
# may already be consumed) — but the failure is still typed as
|
|
499
|
+
# RateLimitError for callers to report distinctly. retries: nil
|
|
500
|
+
# because the streaming path has no retry loop (fresh-eye R5);
|
|
501
|
+
# the drained chunks are the real body — in block form res.body
|
|
502
|
+
# is a Net::ReadAdapter sink, not the bytes (fresh-eye R7).
|
|
503
|
+
raise rate_limit_error(response, nil, body_chunks.join) if status_code == 429
|
|
504
|
+
|
|
505
|
+
raise ClientError, "HTTP Request failed with code #{status_code}: #{Rubik::Platform.safe_utf8(body_chunks.join)}"
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
response.read_body do |chunk|
|
|
509
|
+
parser.feed(chunk).each(&callback)
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# After the connection closes, flush any trailing event.
|
|
514
|
+
parser.flush.each(&callback)
|
|
515
|
+
rescue ClientError,
|
|
516
|
+
*RETRYABLE_ERRORS, IOError, Net::ReadTimeout
|
|
517
|
+
# Propagate untouched so the pooled layer can drop a suspect session
|
|
518
|
+
# and the caller decides on a transparent fresh-socket retry.
|
|
519
|
+
raise
|
|
520
|
+
rescue StandardError => e
|
|
521
|
+
raise ClientError, "Network error during stream from #{uri.host}: #{e.message}"
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|