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,619 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "boot_lifecycle"
|
|
5
|
+
require_relative "client"
|
|
6
|
+
require_relative "proxy_tool"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "../runtime/context"
|
|
9
|
+
|
|
10
|
+
module Rubik
|
|
11
|
+
module MCP
|
|
12
|
+
class Manager
|
|
13
|
+
include BootLifecycle
|
|
14
|
+
|
|
15
|
+
attr_reader :config, :root_dir, :servers, :clients, :tools, :guard
|
|
16
|
+
attr_accessor :tools_registry, :runtime_manager, :context
|
|
17
|
+
|
|
18
|
+
# Frozen-by-copy snapshot of the boot states (Context#services
|
|
19
|
+
# precedent); the guarded write path is transition_server!. The former
|
|
20
|
+
# attr_reader handed out the live hash — an unlocked race against the
|
|
21
|
+
# boot thread. Read through this door.
|
|
22
|
+
def statuses
|
|
23
|
+
@monitor.synchronize { @statuses.dup }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Boot race gate state (mcp-async-boot D5): true while the manager is
|
|
27
|
+
# being torn down, so an in-flight boot thread never applies state into
|
|
28
|
+
# a dying context.
|
|
29
|
+
def stopped?
|
|
30
|
+
@stopped
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(config: nil, tools_registry: nil, runtime_manager: nil, context: nil, root_dir: Dir.pwd, guard: nil,
|
|
34
|
+
client_factory: nil)
|
|
35
|
+
@config = config
|
|
36
|
+
@tools_registry = tools_registry
|
|
37
|
+
@runtime_manager = runtime_manager
|
|
38
|
+
@context = context || runtime_manager&.context
|
|
39
|
+
@root_dir = File.expand_path(root_dir)
|
|
40
|
+
@guard = guard
|
|
41
|
+
# DI seam for tests: production always builds Rubik::MCP::Client.
|
|
42
|
+
@client_factory = client_factory
|
|
43
|
+
|
|
44
|
+
@servers = {}
|
|
45
|
+
@clients = {}
|
|
46
|
+
@contexts = {}
|
|
47
|
+
@tools = {}
|
|
48
|
+
@statuses = {}
|
|
49
|
+
# Boot race guards (mcp-async-boot D5/D6): @stopped flips when the
|
|
50
|
+
# manager is being torn down; :connecting marks are tagged with the
|
|
51
|
+
# boot epoch that owns them (@boot_marks).
|
|
52
|
+
@stopped = false
|
|
53
|
+
@boot_thread = nil
|
|
54
|
+
@boot_marks = {}
|
|
55
|
+
@monitor = Monitor.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def enabled?
|
|
59
|
+
return true unless @config
|
|
60
|
+
|
|
61
|
+
@config.fetch("mcp.enabled", true) != false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns {ok:, tools:} — the config persist outcome plus the registered
|
|
65
|
+
# tool count; ok is false when the disk write failed (the server still
|
|
66
|
+
# runs for this session).
|
|
67
|
+
def add_server(name, defn, auto_start: true, persist: nil)
|
|
68
|
+
name_str = name.to_s
|
|
69
|
+
parsed_defn = defn.is_a?(Hash) ? defn.transform_keys(&:to_s) : {}
|
|
70
|
+
persist_res = { ok: true }
|
|
71
|
+
tools_count = 0
|
|
72
|
+
|
|
73
|
+
@monitor.synchronize do
|
|
74
|
+
@servers[name_str] = parsed_defn
|
|
75
|
+
@statuses[name_str] ||= :disabled
|
|
76
|
+
|
|
77
|
+
if @config && persist && @config.persisting?(persist)
|
|
78
|
+
server_hash = {
|
|
79
|
+
"command" => parsed_defn["command"],
|
|
80
|
+
"args" => Array(parsed_defn["args"]),
|
|
81
|
+
"env" => parsed_defn["env"] || {}
|
|
82
|
+
}
|
|
83
|
+
# Per-server handshake override round-trips only when present,
|
|
84
|
+
# so `!mcp add` output stays minimal (the knob is hand-written
|
|
85
|
+
# config, not a CLI flag).
|
|
86
|
+
server_hash["timeout"] = parsed_defn["timeout"] if parsed_defn["timeout"]
|
|
87
|
+
persist_res = @config.set("mcp.servers.#{name_str}", server_hash, persist: persist)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if auto_start && enabled?
|
|
91
|
+
enable_server(name_str)
|
|
92
|
+
else
|
|
93
|
+
transition_server!(name_str, :disabled)
|
|
94
|
+
end
|
|
95
|
+
tools_count = (@tools[name_str] || []).size
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
persist_res.merge(tools: tools_count)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns the config persist outcome ({ok:, error?:}); the server is
|
|
102
|
+
# always torn down from the live session, deletion is idempotent.
|
|
103
|
+
def remove_server(name, persist: nil)
|
|
104
|
+
name_str = name.to_s
|
|
105
|
+
persist_res = { ok: true }
|
|
106
|
+
@monitor.synchronize do
|
|
107
|
+
disable_server(name_str) if @statuses[name_str] == :active || @clients.key?(name_str)
|
|
108
|
+
|
|
109
|
+
@servers.delete(name_str)
|
|
110
|
+
@contexts.delete(name_str)
|
|
111
|
+
@clients.delete(name_str)
|
|
112
|
+
@tools.delete(name_str)
|
|
113
|
+
clear_server_record!(name_str)
|
|
114
|
+
|
|
115
|
+
persist_res = @config.delete("mcp.servers.#{name_str}", persist: persist) if @config
|
|
116
|
+
end
|
|
117
|
+
persist_res
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Connect phase of enable_server: PURE I/O — client construction,
|
|
121
|
+
# spawn + initialize handshake, tools/list. No monitor, no context
|
|
122
|
+
# wiring, no registry mutation, so config-boot fan-out can overlap
|
|
123
|
+
# many of these on the concurrency boundary while every state change
|
|
124
|
+
# stays sequential in apply_connected_server.
|
|
125
|
+
def connect_server(name_str, server_defn, timeout: nil)
|
|
126
|
+
cmd = server_defn["command"] || server_defn[:command]
|
|
127
|
+
args = server_defn["args"] || server_defn[:args] || []
|
|
128
|
+
env = server_defn["env"] || server_defn[:env] || {}
|
|
129
|
+
base_ctx = @context || @runtime_manager&.context
|
|
130
|
+
connect_timeout = resolve_connect_timeout(server_defn, timeout)
|
|
131
|
+
# Config boundary (D2 mirror): validated for BOTH construction
|
|
132
|
+
# routes — the client_factory DI seam replaces the client, not the
|
|
133
|
+
# manager's own knob validation.
|
|
134
|
+
call_timeout = resolve_call_timeout_config
|
|
135
|
+
|
|
136
|
+
client =
|
|
137
|
+
if @client_factory
|
|
138
|
+
@client_factory.call(cmd, args: args, env: env, name: name_str, root_dir: @root_dir,
|
|
139
|
+
guard: @guard || (base_ctx ? Rubik::Coeffects.get(base_ctx, Rubik::Coeffects::GUARD) : nil))
|
|
140
|
+
else
|
|
141
|
+
Rubik::MCP::Client.new(
|
|
142
|
+
cmd,
|
|
143
|
+
args: args,
|
|
144
|
+
env: env,
|
|
145
|
+
name: name_str,
|
|
146
|
+
root_dir: @root_dir,
|
|
147
|
+
# Resolved live at connect time: the composed kwarg wins, but a
|
|
148
|
+
# Manager built before wire_runtime_context still picks up the
|
|
149
|
+
# session guard once the context provides it (construction-time
|
|
150
|
+
# snapshots of coeffects go stale — this matches base_ctx above).
|
|
151
|
+
guard: @guard || (base_ctx ? Rubik::Coeffects.get(base_ctx, Rubik::Coeffects::GUARD) : nil),
|
|
152
|
+
call_timeout: call_timeout
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
begin
|
|
157
|
+
client.start!(timeout: connect_timeout)
|
|
158
|
+
{ client: client, tools: client.list_tools(timeout: connect_timeout) }
|
|
159
|
+
rescue StandardError, Interrupt => e
|
|
160
|
+
# A failed handshake must not leak the spawned child process (the
|
|
161
|
+
# boot fan-out's slot capture alone cannot stop it). Client#stop is
|
|
162
|
+
# idempotent, so apply-phase rollback stays exactly-once.
|
|
163
|
+
begin
|
|
164
|
+
client.stop
|
|
165
|
+
rescue StandardError => stop_err
|
|
166
|
+
Rubik::Log.warn(:mcp, "failed to stop MCP client '#{name_str}' after failed handshake (#{stop_err.class}: #{stop_err.message})")
|
|
167
|
+
end
|
|
168
|
+
raise e
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Apply phase of enable_server: the post-connect half, extracted
|
|
173
|
+
# verbatim — context wiring FIRST (so any later failure cleans up via
|
|
174
|
+
# the child drain), proxy registration, registry flips, and the
|
|
175
|
+
# original rollback on failure. On ANY failure the client is stopped
|
|
176
|
+
# exactly once: post-wiring via the context drain's on_dispose
|
|
177
|
+
# inverse, pre-wiring via the explicit stop in the rescue (Client#stop
|
|
178
|
+
# is idempotent, so the overlap is harmless). Callers hold @monitor.
|
|
179
|
+
def apply_connected_server(name_str, _server_defn, client:, tools:)
|
|
180
|
+
base_ctx = nil
|
|
181
|
+
server_ctx = nil
|
|
182
|
+
begin
|
|
183
|
+
@contexts[name_str].dispose if @contexts[name_str] && !@contexts[name_str].disposed?
|
|
184
|
+
|
|
185
|
+
base_ctx = @context || @runtime_manager&.context
|
|
186
|
+
server_ctx = base_ctx ? base_ctx.extend : Rubik::Runtime::Context.new
|
|
187
|
+
@contexts[name_str] = server_ctx
|
|
188
|
+
|
|
189
|
+
if base_ctx
|
|
190
|
+
base_ctx.provide("mcp:#{name_str}", client)
|
|
191
|
+
else
|
|
192
|
+
server_ctx.provide("mcp:#{name_str}", client)
|
|
193
|
+
end
|
|
194
|
+
server_ctx.on_dispose { client.stop }
|
|
195
|
+
|
|
196
|
+
server_tools = []
|
|
197
|
+
if @tools_registry
|
|
198
|
+
tools.each do |tdef|
|
|
199
|
+
proxy = Rubik::MCP::ProxyTool.new(server_name: name_str, tool_def: tdef, client: client)
|
|
200
|
+
@tools_registry.register(proxy, origin: :mcp)
|
|
201
|
+
server_tools << proxy
|
|
202
|
+
server_ctx.on_dispose { @tools_registry.unregister(proxy.name) }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
@tools[name_str] = server_tools
|
|
207
|
+
@clients[name_str] = client
|
|
208
|
+
transition_server!(name_str, :active)
|
|
209
|
+
client
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
transition_server!(name_str, :failed)
|
|
212
|
+
server_ctx.dispose if server_ctx && !server_ctx.disposed?
|
|
213
|
+
# Lock order: unprovide OUTSIDE the child drain — child disposal
|
|
214
|
+
# must never acquire an ancestor's monitor (see Context#prune_child).
|
|
215
|
+
base_ctx&.unprovide("mcp:#{name_str}")
|
|
216
|
+
@contexts.delete(name_str)
|
|
217
|
+
@clients.delete(name_str)
|
|
218
|
+
@tools.delete(name_str)
|
|
219
|
+
# Post-wiring failures stopped the client via the ctx drain above;
|
|
220
|
+
# pre-wiring failures (stale-context dispose, ctx.extend) reach
|
|
221
|
+
# only this line — idempotent Client#stop makes it exactly-once.
|
|
222
|
+
begin
|
|
223
|
+
client.stop
|
|
224
|
+
rescue StandardError
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
raise e
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def enable_server(name)
|
|
232
|
+
name_str = name.to_s
|
|
233
|
+
@monitor.synchronize do
|
|
234
|
+
server_defn = @servers[name_str]
|
|
235
|
+
raise Rubik::MCP::Error, "MCP server '#{name_str}' not found" unless server_defn
|
|
236
|
+
|
|
237
|
+
return @clients[name_str] if @statuses[name_str] == :active && @clients[name_str]&.running?
|
|
238
|
+
|
|
239
|
+
connected = begin
|
|
240
|
+
connect_server(name_str, server_defn)
|
|
241
|
+
rescue StandardError
|
|
242
|
+
transition_server!(name_str, :failed)
|
|
243
|
+
raise
|
|
244
|
+
end
|
|
245
|
+
apply_connected_server(name_str, server_defn, **connected)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def disable_server(name)
|
|
250
|
+
name_str = name.to_s
|
|
251
|
+
@monitor.synchronize do
|
|
252
|
+
raise Rubik::MCP::Error, "MCP server '#{name_str}' not found" unless @servers.key?(name_str)
|
|
253
|
+
|
|
254
|
+
base_ctx = @context || @runtime_manager&.context
|
|
255
|
+
ctx = @contexts[name_str]
|
|
256
|
+
if ctx && !ctx.disposed?
|
|
257
|
+
ctx.dispose
|
|
258
|
+
# Lock order: unprovide OUTSIDE the child drain — child disposal
|
|
259
|
+
# must never acquire an ancestor's monitor (see Context#prune_child).
|
|
260
|
+
base_ctx&.unprovide("mcp:#{name_str}")
|
|
261
|
+
elsif @clients[name_str]
|
|
262
|
+
@clients[name_str].stop
|
|
263
|
+
base_ctx&.unprovide("mcp:#{name_str}")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
@contexts.delete(name_str)
|
|
267
|
+
@clients.delete(name_str)
|
|
268
|
+
@tools.delete(name_str)
|
|
269
|
+
transition_server!(name_str, :disabled)
|
|
270
|
+
true
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def connect(name, command, args = [], env = {})
|
|
275
|
+
add_server(name, { "command" => command, "args" => args, "env" => env }, auto_start: true, persist: nil)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def disconnect(name)
|
|
279
|
+
remove_server(name, persist: nil)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def ping(name)
|
|
283
|
+
name_str = name.to_s
|
|
284
|
+
client = @monitor.synchronize { @clients[name_str] }
|
|
285
|
+
raise Rubik::MCP::Error, "MCP server '#{name_str}' is not running" unless client&.running?
|
|
286
|
+
|
|
287
|
+
client.ping
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Config boot dispatcher (mcp-async-boot D4): synchronous by default,
|
|
291
|
+
# or spawned onto a named background thread with async: true. The
|
|
292
|
+
# thread is returned in async mode (test seam); sync mode returns nil
|
|
293
|
+
# exactly as before. Repeated async boots first drain any previous
|
|
294
|
+
# boot thread (AstWarmup precedent) and re-open the @stopped gate; the
|
|
295
|
+
# sync path re-opens it only when no boot thread is alive, so a wedged
|
|
296
|
+
# background boot stays gated after a stop_all.
|
|
297
|
+
def load_from_config(cfg = nil, async: false)
|
|
298
|
+
unless async
|
|
299
|
+
@monitor.synchronize { @stopped = false if @boot_thread.nil? }
|
|
300
|
+
return perform_load_from_config(cfg)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
join_boot_safely(2.0)
|
|
304
|
+
@monitor.synchronize { @stopped = false }
|
|
305
|
+
thread = Thread.new do
|
|
306
|
+
perform_load_from_config(cfg)
|
|
307
|
+
rescue StandardError, Interrupt => e
|
|
308
|
+
Rubik::Log.warn(:mcp, "background MCP boot failed (#{e.class}: #{e.message})")
|
|
309
|
+
ensure
|
|
310
|
+
# Only clear OUR reference: a wedged boot A expiring past the drain
|
|
311
|
+
# bound must not clobber the handle to a newer boot B's thread.
|
|
312
|
+
@monitor.synchronize { @boot_thread = nil if @boot_thread.equal?(Thread.current) }
|
|
313
|
+
end
|
|
314
|
+
thread.name = "rubik-mcp-boot" if thread.respond_to?(:name=)
|
|
315
|
+
@boot_thread = thread
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
private
|
|
319
|
+
|
|
320
|
+
# Config boot, decomposed into the named phases over the concurrency
|
|
321
|
+
# boundary so N servers' connect handshakes (spawn + initialize +
|
|
322
|
+
# tools/list — the slow I/O) overlap while every observable state
|
|
323
|
+
# change stays sequential in config order. Boot wall clock goes
|
|
324
|
+
# sum(per-server) → max(per-server); one hung server no longer delays
|
|
325
|
+
# the rest. Pure orchestration below: each phase is a private method,
|
|
326
|
+
# moved verbatim from the former single body (zero logic change).
|
|
327
|
+
def perform_load_from_config(cfg = nil)
|
|
328
|
+
target_config = cfg || @config
|
|
329
|
+
return unless target_config
|
|
330
|
+
|
|
331
|
+
mcp_enabled = target_config.fetch("mcp.enabled", true) != false
|
|
332
|
+
servers_cfg = target_config.fetch("mcp.servers", {})
|
|
333
|
+
return unless servers_cfg.is_a?(Hash)
|
|
334
|
+
|
|
335
|
+
# Boot epoch (review round): :connecting marks are tagged with a
|
|
336
|
+
# per-invocation identity so a wedged older boot's failure branch or
|
|
337
|
+
# ensure sweep can never downgrade a newer boot's fresh mark.
|
|
338
|
+
boot_epoch = Object.new
|
|
339
|
+
entries = register_config_servers(servers_cfg, mcp_enabled, boot_epoch)
|
|
340
|
+
|
|
341
|
+
# Phase 1 deliberately did no context wiring, so stopping the client
|
|
342
|
+
# is the ONLY cleanup an unapplied prepared slot owes — no orphaned
|
|
343
|
+
# child processes, even when an interrupt lands mid-boot. One ensure
|
|
344
|
+
# spans both post-registration phases.
|
|
345
|
+
connected = ::Array.new(entries.size)
|
|
346
|
+
outcomes = {}
|
|
347
|
+
begin
|
|
348
|
+
connect_fanout(entries, connected)
|
|
349
|
+
apply_connected_slots(entries, connected, boot_epoch, outcomes)
|
|
350
|
+
ensure
|
|
351
|
+
stop_unapplied_clients(entries, connected, outcomes)
|
|
352
|
+
fail_orphaned_connecting_marks(entries, boot_epoch)
|
|
353
|
+
end
|
|
354
|
+
nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Phase 0 (sequential, config order): registration still goes
|
|
358
|
+
# through add_server — subclasses and plugins wrap that seam, and a
|
|
359
|
+
# broken definition must fail exactly as before (logged, skipped,
|
|
360
|
+
# not half-registered). auto_start is deliberately false here: the
|
|
361
|
+
# connect half moved to the fan-out. Ends each auto-start candidate
|
|
362
|
+
# with the phase-0 claim (D5) and returns the entries to boot.
|
|
363
|
+
def register_config_servers(servers_cfg, mcp_enabled, boot_epoch)
|
|
364
|
+
entries = []
|
|
365
|
+
servers_cfg.each do |sname, sdefn|
|
|
366
|
+
next unless sdefn.is_a?(Hash)
|
|
367
|
+
|
|
368
|
+
auto_start = mcp_enabled && (sdefn.fetch("enabled", true) != false)
|
|
369
|
+
begin
|
|
370
|
+
add_server(sname, sdefn, auto_start: false, persist: nil)
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
# ENSURE-LOG (G3 round-1): one broken server config used to
|
|
373
|
+
# vanish behind an ENV["RUBIK_DEBUG"] gate; a misconfigured
|
|
374
|
+
# server must surface without debug mode.
|
|
375
|
+
Rubik::Log.warn(:mcp, "failed to load MCP server '#{sname}' (#{e.class}: #{e.message})")
|
|
376
|
+
emit_mcp_event(:mcp_server_failed, :err, sname.to_s, error: e.message)
|
|
377
|
+
next
|
|
378
|
+
end
|
|
379
|
+
next unless auto_start
|
|
380
|
+
|
|
381
|
+
name_str = sname.to_s
|
|
382
|
+
# Phase-0 claim (D5): check+mark in ONE monitor hold — a user
|
|
383
|
+
# enable/disable/remove that races the boot reads a definite
|
|
384
|
+
# state, and phase 2's gate below can never miss a window.
|
|
385
|
+
entries << [name_str, sdefn.transform_keys(&:to_s)] unless claim_boot_slot!(name_str, boot_epoch)
|
|
386
|
+
end
|
|
387
|
+
entries
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Phase 1 (fan-out): pure I/O connects. Slots are captured by SIDE
|
|
391
|
+
# EFFECT inside the item blocks: when an Exception-level error (a
|
|
392
|
+
# boot interrupt) kills .map before it returns, the assignment below
|
|
393
|
+
# never happens — a value-only `connected` would leave the ensure
|
|
394
|
+
# blind to every successfully-started client. Hence the pre-built
|
|
395
|
+
# array passed in rather than a returned value.
|
|
396
|
+
def connect_fanout(entries, connected)
|
|
397
|
+
return if entries.empty?
|
|
398
|
+
|
|
399
|
+
Rubik::Concurrency.map(entries.each_with_index.to_a) do |(name_str, parsed_defn), index|
|
|
400
|
+
connected[index] = { ok: true, **connect_server(name_str, parsed_defn) }
|
|
401
|
+
rescue StandardError => e
|
|
402
|
+
connected[index] = { ok: false, error: e }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Phase 2 (sequential, config order): apply state; identical
|
|
407
|
+
# per-server warning on either phase's failure. Outcomes ledger is an
|
|
408
|
+
# explicit atom map (index -> :applied | :failed; absence = skipped or
|
|
409
|
+
# never ran — exactly what owes_stop? reads).
|
|
410
|
+
def apply_connected_slots(entries, connected, boot_epoch, outcomes)
|
|
411
|
+
entries.each_with_index do |(name_str, parsed_defn), index|
|
|
412
|
+
slot = connected[index]
|
|
413
|
+
next unless slot # never connected (interrupt during phase 1)
|
|
414
|
+
|
|
415
|
+
if slot[:ok]
|
|
416
|
+
begin
|
|
417
|
+
outcome = @monitor.synchronize do
|
|
418
|
+
# Single race gate (D5), checked at apply time under the
|
|
419
|
+
# monitor: a disable/remove/enable during boot wins over
|
|
420
|
+
# the boot's own apply, and a stop_all skips it entirely.
|
|
421
|
+
# Epoch-complete gate: this boot may only apply into the
|
|
422
|
+
# slot its own phase-0 claim marked, so a user decision
|
|
423
|
+
# that moved the slot or a newer boot's fresh mark wins.
|
|
424
|
+
# In the synchronous path nothing can interleave, so the
|
|
425
|
+
# guard is provably inert there.
|
|
426
|
+
if @stopped || @statuses[name_str] != :connecting || !@boot_marks[name_str].equal?(boot_epoch)
|
|
427
|
+
:skipped
|
|
428
|
+
else
|
|
429
|
+
apply_connected_server(name_str, parsed_defn, client: slot[:client], tools: slot[:tools])
|
|
430
|
+
# Post-apply revalidation under the boot actor: a no-op
|
|
431
|
+
# when apply already landed :active.
|
|
432
|
+
transition_server!(name_str, :active, epoch: boot_epoch)
|
|
433
|
+
:applied
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
outcomes[index] = :applied if outcome == :applied
|
|
437
|
+
emit_mcp_event(:mcp_server_loaded, :info, name_str, tools: (@tools[name_str] || []).size) if outcome == :applied
|
|
438
|
+
rescue StandardError => e
|
|
439
|
+
# ENSURE-LOG (G3 round-1): one broken server config used to
|
|
440
|
+
# vanish behind an ENV["RUBIK_DEBUG"] gate; a misconfigured
|
|
441
|
+
# server must surface without debug mode. apply's own
|
|
442
|
+
# rollback already set :failed and stopped its client via
|
|
443
|
+
# the context drain; :failed marks the slot so the ensure
|
|
444
|
+
# never double-stops it.
|
|
445
|
+
outcomes[index] = :failed
|
|
446
|
+
Rubik::Log.warn(:mcp, "failed to load MCP server '#{name_str}' (#{e.class}: #{e.message})")
|
|
447
|
+
emit_mcp_event(:mcp_server_failed, :err, name_str, error: e.message)
|
|
448
|
+
end
|
|
449
|
+
else
|
|
450
|
+
# Only our own epoch's mark may flip to :failed — a user
|
|
451
|
+
# disable/remove during the handshake, or a newer boot's fresh
|
|
452
|
+
# mark, must win over this boot's connect failure (D5). The
|
|
453
|
+
# epoch gate and flip happen in the same monitor hold inside
|
|
454
|
+
# transition_server!.
|
|
455
|
+
transition_server!(name_str, :failed, epoch: boot_epoch)
|
|
456
|
+
error = slot[:error]
|
|
457
|
+
Rubik::Log.warn(:mcp, "failed to load MCP server '#{name_str}' (#{error.class}: #{error.message})")
|
|
458
|
+
emit_mcp_event(:mcp_server_failed, :err, name_str, error: error.message)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Ensure part 1: stop every prepared client that did not end up
|
|
464
|
+
# registered: a failed apply already rolled its own client back via
|
|
465
|
+
# the context drain, while a never-applied one (interrupt between
|
|
466
|
+
# phases) owes a stop here. A :skipped slot also owes a stop —
|
|
467
|
+
# skipping the APPLY must not orphan its prepared child process (that
|
|
468
|
+
# is the exact leak D10 closed for handshakes). owes_stop? is the
|
|
469
|
+
# structural form of that rule: skipped and never-ran slots owe.
|
|
470
|
+
def stop_unapplied_clients(entries, connected, outcomes)
|
|
471
|
+
entries.each_with_index do |(name_str, _defn), index|
|
|
472
|
+
slot = connected[index]
|
|
473
|
+
next if !slot.is_a?(Hash) || !slot[:client] || !BootLifecycle.owes_stop?(outcomes[index])
|
|
474
|
+
|
|
475
|
+
registered = @monitor.synchronize { @clients[name_str] }
|
|
476
|
+
next if registered.equal?(slot[:client])
|
|
477
|
+
|
|
478
|
+
begin
|
|
479
|
+
slot[:client].stop
|
|
480
|
+
rescue StandardError => e
|
|
481
|
+
Rubik::Log.warn(:mcp, "failed to stop unapplied MCP server '#{name_str}' (#{e.class}: #{e.message})")
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Ensure part 2 — terminal state for boot entries that never reached
|
|
487
|
+
# apply (D6): anything still :connecting failed to boot; user-chosen
|
|
488
|
+
# values (:disabled, :active, deleted-by-remove) are left untouched.
|
|
489
|
+
# The epoch check inside transition_server! keeps an older wedged
|
|
490
|
+
# boot's sweep from downgrading a newer boot's fresh mark.
|
|
491
|
+
def fail_orphaned_connecting_marks(entries, boot_epoch)
|
|
492
|
+
entries.each do |(name_str, _defn)|
|
|
493
|
+
transition_server!(name_str, :failed, epoch: boot_epoch)
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
public
|
|
498
|
+
|
|
499
|
+
# Teardown (mcp-async-boot slice 3): arms the boot gate, drains an
|
|
500
|
+
# in-flight boot thread, then disables every server. The join happens
|
|
501
|
+
# OUTSIDE the monitor (G5): the boot thread takes @monitor in its apply
|
|
502
|
+
# phase, so joining under it would deadlock.
|
|
503
|
+
def stop_all(join_timeout: 2.0)
|
|
504
|
+
@monitor.synchronize { @stopped = true }
|
|
505
|
+
join_boot_safely(join_timeout)
|
|
506
|
+
|
|
507
|
+
@monitor.synchronize do
|
|
508
|
+
@servers.each_key do |name|
|
|
509
|
+
disable_server(name)
|
|
510
|
+
rescue StandardError => e
|
|
511
|
+
Rubik::Log.warn(:mcp, "failed to stop MCP server '#{name}' during teardown (#{e.class}: #{e.message})")
|
|
512
|
+
nil
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
alias dispose! stop_all
|
|
517
|
+
alias dispose stop_all
|
|
518
|
+
|
|
519
|
+
def status_summary
|
|
520
|
+
@monitor.synchronize do
|
|
521
|
+
@servers.map do |name, _defn|
|
|
522
|
+
server_status(name)
|
|
523
|
+
end.compact
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def server_status(name)
|
|
528
|
+
name_str = name.to_s
|
|
529
|
+
defn = @servers[name_str]
|
|
530
|
+
return nil unless defn
|
|
531
|
+
|
|
532
|
+
cmd_display = ([defn["command"] || defn[:command]] + Array(defn["args"] || defn[:args])).compact.join(" ")
|
|
533
|
+
{
|
|
534
|
+
name: name_str,
|
|
535
|
+
status: @statuses[name_str] || :disabled,
|
|
536
|
+
tools_count: (@tools[name_str] || []).size,
|
|
537
|
+
tools: (@tools[name_str] || []).map(&:name),
|
|
538
|
+
command: cmd_display
|
|
539
|
+
}
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def client(name)
|
|
543
|
+
@monitor.synchronize { @clients[name.to_s] }
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def active_tools
|
|
547
|
+
@monitor.synchronize { @tools.values.flatten }
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
private
|
|
551
|
+
|
|
552
|
+
# Boot observability door (mcp-async-boot D7): publish through the
|
|
553
|
+
# session event bus so the dispatcher's dmesg door records boot
|
|
554
|
+
# outcomes under the "mcp" subsystem. post (enqueue) — never emit
|
|
555
|
+
# (drain): the caller may be the boot thread. An unresolved bus
|
|
556
|
+
# (headless manager) is a no-op; a failing post is a warn, never a
|
|
557
|
+
# boot failure.
|
|
558
|
+
def emit_mcp_event(event_name, level, name_str, **data)
|
|
559
|
+
ctx = @context || @runtime_manager&.context
|
|
560
|
+
bus = Rubik::Coeffects.get(ctx, Rubik::Coeffects::EVENTS)
|
|
561
|
+
return unless bus.respond_to?(:post)
|
|
562
|
+
|
|
563
|
+
bus.post(event_name, { server: name_str }, subsystem: :mcp, level: level, **data)
|
|
564
|
+
rescue StandardError => e
|
|
565
|
+
Rubik::Log.warn(:mcp, "failed to emit MCP event '#{event_name}' (#{e.class}: #{e.message})")
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Bounded drain of the boot thread. Mechanics live in
|
|
569
|
+
# Concurrency::Join (monotonic-deadline poll, never raises); the
|
|
570
|
+
# still-alive warn and the :mcp log channel stay here. Reads
|
|
571
|
+
# @boot_thread under the monitor but joins OUTSIDE it (G5).
|
|
572
|
+
def join_boot_safely(timeout = nil)
|
|
573
|
+
result = Rubik::Concurrency::Join.thread(@monitor.synchronize { @boot_thread }, timeout: timeout)
|
|
574
|
+
case result
|
|
575
|
+
when :timeout
|
|
576
|
+
Rubik::Log.warn(:mcp, "MCP boot thread still alive after #{timeout}s join; continuing (stop gate armed)")
|
|
577
|
+
when StandardError
|
|
578
|
+
Rubik::Log.warn(:mcp, "MCP boot thread join failed (#{result.class}: #{result.message})")
|
|
579
|
+
end
|
|
580
|
+
nil
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
# Join.thread never raises; this covers the monitor acquisition
|
|
583
|
+
# (and Log itself) — hence "drain", not "join".
|
|
584
|
+
Rubik::Log.warn(:mcp, "MCP boot thread drain failed (#{e.class}: #{e.message})")
|
|
585
|
+
nil
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# mcp.call_timeout at the config boundary (D2 mirror): an invalid
|
|
589
|
+
# value warns and falls through to the client default, exactly like a
|
|
590
|
+
# bad mcp.timeout — the knob must never fail the boot silently.
|
|
591
|
+
def resolve_call_timeout_config
|
|
592
|
+
raw = @config&.fetch("mcp.call_timeout", nil)
|
|
593
|
+
return nil if raw.nil?
|
|
594
|
+
return raw if raw.is_a?(Numeric) && raw.positive?
|
|
595
|
+
|
|
596
|
+
Rubik::Log.warn(:mcp, "ignoring non-positive MCP call_timeout #{raw.inspect}; using the client default")
|
|
597
|
+
nil
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Handshake timeout resolution (explicit kwarg > per-server "timeout" >
|
|
601
|
+
# mcp.timeout config > DEFAULT_TIMEOUT). A non-positive value warns and
|
|
602
|
+
# falls through to the next source — a miswritten knob must never fail
|
|
603
|
+
# the boot at the config boundary.
|
|
604
|
+
def resolve_connect_timeout(server_defn, explicit)
|
|
605
|
+
sources = [explicit, server_defn["timeout"] || server_defn[:timeout]]
|
|
606
|
+
cfg_timeout = @config&.fetch("mcp.timeout", nil)
|
|
607
|
+
sources << cfg_timeout unless cfg_timeout.nil?
|
|
608
|
+
|
|
609
|
+
sources.each do |candidate|
|
|
610
|
+
return candidate if candidate.is_a?(Numeric) && candidate.positive?
|
|
611
|
+
next if candidate.nil?
|
|
612
|
+
|
|
613
|
+
Rubik::Log.warn(:mcp, "ignoring non-positive MCP timeout #{candidate.inspect}; falling back to the next source")
|
|
614
|
+
end
|
|
615
|
+
Client::DEFAULT_TIMEOUT
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
end
|