rubik 0.4.1 → 0.4.2
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 +4 -4
- data/CHANGELOG.md +71 -1
- data/lib/rubik/ast/cache_store.rb +36 -5
- data/lib/rubik/ast/extraction_result.rb +6 -0
- data/lib/rubik/ast/graph.rb +31 -5
- data/lib/rubik/ast/ruby_extractor.rb +8 -2
- data/lib/rubik/buffer/apply_contract.rb +6 -0
- data/lib/rubik/buffer/manager.rb +37 -27
- data/lib/rubik/mcp/client.rb +4 -0
- data/lib/rubik/mcp/manager.rb +51 -35
- data/lib/rubik/mcp/proxy_tool.rb +17 -0
- data/lib/rubik/mcp/transport/stdio.rb +142 -17
- data/lib/rubik/plugin/manager.rb +39 -15
- data/lib/rubik/plugin/package_inventory.rb +11 -1
- data/lib/rubik/plugin/storage.rb +5 -4
- data/lib/rubik/repair/loop.rb +29 -15
- data/lib/rubik/storage/vakanuvis/vocabulary.rb +33 -2
- data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
- data/lib/rubik/ui/capability.rb +19 -5
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8907beb9d8441f6f497af9bd1b65a998c5dfa1d2857552943809ace7270e3759
|
|
4
|
+
data.tar.gz: a4b938aa55ff08037d63fc00d545a5039c06319c2ecee309e85d369d5d752621
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a034a02d012190d0f6fe02c1b7977628d60617fb0bca562c3b4799dd6512e5cb77809540d57ebe4eede099a0c8e724a679579cc75f4db85f9a8e59bd535d59ea
|
|
7
|
+
data.tar.gz: bc8162afcace2ab99fca16b749a5291abeac662e2255f5d798277931a7295a447bdc981bb5d9bc897afa80a15db8fe836771536c3441c110208546442b280622
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,75 @@ commits, 2026-08-23 → 2026-10-09, see `git log`).
|
|
|
6
6
|
|
|
7
7
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
8
8
|
|
|
9
|
+
## [0.4.2] - 2026-10-29
|
|
10
|
+
|
|
11
|
+
A reliability patch: plugin file loads become transactional with
|
|
12
|
+
timing-safe integrity checks, MCP transport and handshake lifecycles
|
|
13
|
+
shed their remaining deadlocks and wedged-boot races, buffer apply
|
|
14
|
+
failures report faithfully, the repair loop can no longer return
|
|
15
|
+
unverified code as success, and vakanüvis slugs stop colliding across
|
|
16
|
+
scripts and long topics.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **MCP transport lifecycle deadlocks closed (#308):** server stderr is
|
|
21
|
+
drained on a dedicated thread with a bounded diagnostic tail (also
|
|
22
|
+
attached to `TimeoutError` messages), a dead transport incarnation is
|
|
23
|
+
reaped on re-open instead of leaking its file descriptors and waiter,
|
|
24
|
+
and a fast async boot can no longer park a dead thread handle that
|
|
25
|
+
permanently wedged the sync-boot gate after `stop_all`.
|
|
26
|
+
- **AST persistent cache no longer pins stale symbols (#308):** a failed
|
|
27
|
+
re-index is excluded from the cache snapshot so its protected stale
|
|
28
|
+
symbols are never validated under the current file's fresh digest;
|
|
29
|
+
per-entry extraction status is persisted and validated on load
|
|
30
|
+
(cache schema version 2, backward compatible with existing caches).
|
|
31
|
+
- **Plugin file loads are transactional (#334):** `load_plugin_file`
|
|
32
|
+
prepares the replacement before touching the running plugin so a
|
|
33
|
+
failed re-add no longer bricks the live plugin; reloading a previously
|
|
34
|
+
`:active` plugin that lands in `:pending` rolls back to the last
|
|
35
|
+
working version; the manifest TOCTOU window is closed by evaluating
|
|
36
|
+
the in-memory verified content via `TOPLEVEL_BINDING.eval`; manifest
|
|
37
|
+
SHA-256 checks use timing-safe `secure_compare`; and directory
|
|
38
|
+
creation failures are always reported with `Rubik::Log.warn`.
|
|
39
|
+
- **MCP handshake and boot races closed (#336):** the manager's
|
|
40
|
+
handshake lock is split so a slow connect no longer serializes
|
|
41
|
+
unrelated state reads; async boots carry epoch-tagged guards that
|
|
42
|
+
discard a superseded connection instead of adopting it; the stdio
|
|
43
|
+
transport gains a synchronized reaper and a bounded `stderr_tail`
|
|
44
|
+
diagnostic; and rollback paths log the client they stopped.
|
|
45
|
+
- **Repair loop no longer reports unverified code as success (#335):**
|
|
46
|
+
a missing diagnostics adapter, an empty LLM proposal, or an exception
|
|
47
|
+
mid-repair now undoes the buffer snapshot and returns `ok: false`
|
|
48
|
+
(flagging `unverified:` when syntax could not be checked) instead of
|
|
49
|
+
writing broken code; LSP rollback only restores clients that are
|
|
50
|
+
alive and running.
|
|
51
|
+
- **Buffer apply failures report faithfully (#333):** `apply!` commits
|
|
52
|
+
`last_applied` and `last_conflicts` in an `ensure` block so files
|
|
53
|
+
already written to disk before a failure are remembered and
|
|
54
|
+
`clean_buffer_message` no longer hides them; garbage-skipped edits
|
|
55
|
+
are tracked in `last_skipped`/`ApplyFailedError.skipped` and surfaced
|
|
56
|
+
in `ApplyContract.failure_lines` and tool outputs; and conflict event
|
|
57
|
+
payloads are frozen copies, closing the live-array aliasing mutation.
|
|
58
|
+
- **Vakanüvis slugs no longer collide across scripts and topics
|
|
59
|
+
(#332):** slug generation Unicode-folds and transliterates Turkish
|
|
60
|
+
characters (ç, ğ, ı, ö, ş, ü) and appends a deterministic 6-character
|
|
61
|
+
SHA-256 suffix when normalization is lossy, when the topic exceeds the
|
|
62
|
+
40-char limit, or when punctuation-only input yields the untitled
|
|
63
|
+
fallback — distinct topics can no longer resolve to the same storage
|
|
64
|
+
key.
|
|
65
|
+
- **UI capability detection unwraps capture streams (#331):**
|
|
66
|
+
`Capability.resolve_mode`, `color?`, and `spinner?` unwrap `TeeIO`
|
|
67
|
+
targets so a live terminal wrapped in a capture stream renders styled
|
|
68
|
+
dialogs and notices again; non-empty truthy `CLICOLOR_FORCE` values
|
|
69
|
+
(e.g. `true`, `yes`) now force ANSI color while `NO_COLOR` keeps
|
|
70
|
+
precedence.
|
|
71
|
+
|
|
72
|
+
### Performance
|
|
73
|
+
|
|
74
|
+
- **Prism `CallNode` filter lists hoisted to frozen Sets (#307):** the
|
|
75
|
+
Ruby extractor's hot walk no longer allocates two temporary Arrays per
|
|
76
|
+
call node (~140k fewer heap objects per 10k file extractions).
|
|
77
|
+
|
|
9
78
|
## [0.4.1] - 2026-10-29
|
|
10
79
|
|
|
11
80
|
A hardening patch: CodeQL-driven fixes remove ReDoS-prone regular
|
|
@@ -892,6 +961,7 @@ installation section.
|
|
|
892
961
|
- Context: serialized index mutations, snapshot-outside-lock reconcile,
|
|
893
962
|
streaming-first truncation.
|
|
894
963
|
|
|
895
|
-
[Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.4.
|
|
964
|
+
[Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.4.2...HEAD
|
|
965
|
+
[0.4.2]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...v0.4.2
|
|
896
966
|
[0.4.1]: https://github.com/ozgurulukir/rubik/compare/v0.4.0...v0.4.1
|
|
897
967
|
[0.1.0]: https://github.com/ozgurulukir/rubik/releases/tag/v0.1.0
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "digest"
|
|
6
6
|
require_relative "symbol"
|
|
7
|
+
require_relative "extraction_result"
|
|
7
8
|
require_relative "../platform"
|
|
8
9
|
|
|
9
10
|
module Rubik
|
|
@@ -44,9 +45,11 @@ module Rubik
|
|
|
44
45
|
File.join(@home_dir, ".rubik", "cache", project_hash, "ast_index.json")
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
def save(file_symbols_hash)
|
|
48
|
+
def save(file_symbols_hash, statuses: {})
|
|
48
49
|
return false unless file_symbols_hash.is_a?(Hash)
|
|
49
50
|
|
|
51
|
+
statuses = {} unless statuses.is_a?(Hash)
|
|
52
|
+
|
|
50
53
|
data = {
|
|
51
54
|
"version" => SCHEMA_VERSION,
|
|
52
55
|
"last_scan" => Time.now.to_i,
|
|
@@ -84,12 +87,17 @@ module Rubik
|
|
|
84
87
|
|
|
85
88
|
# Bodies are intentionally NOT persisted: they dominate the cache
|
|
86
89
|
# size and are re-extracted lazily from source via Symbol#body.
|
|
90
|
+
# `status` carries the issue-#147 extraction status so the
|
|
91
|
+
# loaded graph can keep telling "parsed clean" from "partial" —
|
|
92
|
+
# entries predating the field read as :ok, the only status they
|
|
93
|
+
# could ever have been saved under.
|
|
87
94
|
data["files"][rel_path] = {
|
|
88
95
|
"language" => lang,
|
|
89
96
|
"mtime" => stat.mtime.to_i,
|
|
90
97
|
"mtime_f" => stat.mtime.to_f,
|
|
91
98
|
"size" => stat.size,
|
|
92
99
|
"digest" => digest,
|
|
100
|
+
"status" => (statuses[file_path] || :ok).to_s,
|
|
93
101
|
"symbols" => syms.map { |sym| sym.to_h(include_body: false) }
|
|
94
102
|
}
|
|
95
103
|
end
|
|
@@ -103,14 +111,22 @@ module Rubik
|
|
|
103
111
|
end
|
|
104
112
|
|
|
105
113
|
def load
|
|
114
|
+
load_with_statuses[0]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Same entries as #load, paired with the persisted per-file extraction
|
|
118
|
+
# status keyed by the same expanded paths (issue #147 across the cache
|
|
119
|
+
# boundary: a cache-loaded file must not report "never attempted").
|
|
120
|
+
def load_with_statuses
|
|
106
121
|
target_path = cache_path
|
|
107
|
-
return {} unless File.exist?(target_path)
|
|
122
|
+
return [{}, {}] unless File.exist?(target_path)
|
|
108
123
|
|
|
109
124
|
content = File.read(target_path)
|
|
110
125
|
data = JSON.parse(content)
|
|
111
|
-
return {} unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
|
|
126
|
+
return [{}, {}] unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
|
|
112
127
|
|
|
113
128
|
result = {}
|
|
129
|
+
statuses = {}
|
|
114
130
|
data["files"].each do |rel_path, cached_info|
|
|
115
131
|
full_path = File.expand_path(rel_path, @root_dir)
|
|
116
132
|
next unless valid_entry?(full_path, cached_info)
|
|
@@ -132,14 +148,29 @@ module Rubik
|
|
|
132
148
|
)
|
|
133
149
|
end
|
|
134
150
|
result[full_path] = symbols
|
|
151
|
+
raw_status = cached_info["status"]
|
|
152
|
+
# Entries predating the field had exactly one possible status;
|
|
153
|
+
# anything else unreadable is corrupt data, not a status — warn
|
|
154
|
+
# and fall back instead of materializing an arbitrary symbol that
|
|
155
|
+
# the symbols tool would report and save_to_cache would honor.
|
|
156
|
+
statuses[full_path] = if raw_status.nil?
|
|
157
|
+
:ok
|
|
158
|
+
else
|
|
159
|
+
status = raw_status.is_a?(String) ? raw_status.to_sym : nil
|
|
160
|
+
unless ExtractionResult::STATUSES.include?(status)
|
|
161
|
+
Rubik::Log.warn(:ast, "unknown cached extraction status #{raw_status.inspect} for #{rel_path}; treating as ok")
|
|
162
|
+
status = :ok
|
|
163
|
+
end
|
|
164
|
+
status
|
|
165
|
+
end
|
|
135
166
|
end
|
|
136
167
|
|
|
137
|
-
result
|
|
168
|
+
[result, statuses]
|
|
138
169
|
rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES, TypeError, NoMethodError
|
|
139
170
|
# Corrupt-but-valid JSON shapes raise TypeError/NoMethodError inside
|
|
140
171
|
# the entry loop; EACCES covers an unreadable cache file. All mean
|
|
141
172
|
# "cache unusable" → rebuild from scratch.
|
|
142
|
-
{}
|
|
173
|
+
[{}, {}]
|
|
143
174
|
end
|
|
144
175
|
|
|
145
176
|
def valid_entry?(file_path, cached_info)
|
|
@@ -33,5 +33,11 @@ module Rubik
|
|
|
33
33
|
[:ok, :partial].include?(status)
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
|
+
|
|
37
|
+
class ExtractionResult
|
|
38
|
+
# The closed status vocabulary, shared with persistence seams: the AST
|
|
39
|
+
# cache persists it per entry and validates against it on load.
|
|
40
|
+
STATUSES = [:ok, :partial, :failed, :unavailable].freeze
|
|
41
|
+
end
|
|
36
42
|
end
|
|
37
43
|
end
|
data/lib/rubik/ast/graph.rb
CHANGED
|
@@ -89,7 +89,7 @@ module Rubik
|
|
|
89
89
|
def load_from_cache(cache_store)
|
|
90
90
|
return 0 unless cache_store
|
|
91
91
|
|
|
92
|
-
loaded_files = cache_store.
|
|
92
|
+
loaded_files, statuses = cache_store.load_with_statuses
|
|
93
93
|
return 0 if loaded_files.nil? || loaded_files.empty?
|
|
94
94
|
|
|
95
95
|
@mutex.synchronize do
|
|
@@ -99,6 +99,7 @@ module Rubik
|
|
|
99
99
|
lang = CacheStore::LANGUAGE_MAP[ext] || "code"
|
|
100
100
|
|
|
101
101
|
@file_symbols[file_path] = syms.dup
|
|
102
|
+
@extraction_status[file_path] = statuses[file_path] || :ok
|
|
102
103
|
syms.each do |sym|
|
|
103
104
|
@symbols[sym.name] = sym
|
|
104
105
|
q_key = "#{lang}:#{file_path}##{sym.name}"
|
|
@@ -113,12 +114,32 @@ module Rubik
|
|
|
113
114
|
def save_to_cache(cache_store)
|
|
114
115
|
return false unless cache_store
|
|
115
116
|
|
|
116
|
-
snapshot =
|
|
117
|
-
|
|
117
|
+
snapshot = nil
|
|
118
|
+
statuses = nil
|
|
119
|
+
skipped_failed = 0
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
snapshot = @file_symbols.each_with_object({}) do |(file_path, symbols), copy|
|
|
122
|
+
# Failed-extraction honesty across the cache boundary: a :failed
|
|
123
|
+
# file's in-memory symbols are protected stale data or
|
|
124
|
+
# untrustworthy best-effort fallback. Persisting them would let
|
|
125
|
+
# CacheStore re-stat the CURRENT file and pin them under a fresh
|
|
126
|
+
# digest, making the staleness permanent — skip them and let the
|
|
127
|
+
# next boot re-extract.
|
|
128
|
+
if _get_extraction_status_unlocked(file_path) == :failed
|
|
129
|
+
skipped_failed += 1
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
118
133
|
copy[file_path] = symbols.dup
|
|
119
134
|
end
|
|
135
|
+
statuses = {}
|
|
136
|
+
snapshot.each_key do |file_path|
|
|
137
|
+
statuses[file_path] = _get_extraction_status_unlocked(file_path) || :ok
|
|
138
|
+
end
|
|
120
139
|
end
|
|
121
|
-
|
|
140
|
+
skipped = "cache snapshot excluded #{skipped_failed} file(s) with failed extractions; they re-extract on next boot"
|
|
141
|
+
Rubik::Log.warn(:ast, skipped) if skipped_failed.positive?
|
|
142
|
+
cache_store.save(snapshot, statuses: statuses)
|
|
122
143
|
end
|
|
123
144
|
|
|
124
145
|
def smart_context_for(symbol_name)
|
|
@@ -179,7 +200,6 @@ module Rubik
|
|
|
179
200
|
Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); keeping previously indexed symbols")
|
|
180
201
|
return existing
|
|
181
202
|
end
|
|
182
|
-
Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); no symbols indexed")
|
|
183
203
|
end
|
|
184
204
|
|
|
185
205
|
_remove_file_unlocked(file_path)
|
|
@@ -188,6 +208,12 @@ module Rubik
|
|
|
188
208
|
lang = CacheStore::LANGUAGE_MAP[ext] || "code"
|
|
189
209
|
|
|
190
210
|
extracted = result.symbols
|
|
211
|
+
if result.status == :failed
|
|
212
|
+
# Truthful accounting: the fallback symbols ARE indexed when
|
|
213
|
+
# nothing pre-existing needed protection — even when there are
|
|
214
|
+
# none, the log must not claim otherwise.
|
|
215
|
+
Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); indexing #{extracted.length} best-effort fallback symbols")
|
|
216
|
+
end
|
|
191
217
|
extracted.each do |sym|
|
|
192
218
|
@symbols[sym.name] = sym
|
|
193
219
|
q_key = "#{lang}:#{file_path}##{sym.name}"
|
|
@@ -8,6 +8,10 @@ module Rubik
|
|
|
8
8
|
module AST
|
|
9
9
|
class RubyExtractor
|
|
10
10
|
EXCLUDED_CALLS = Set.new(["def", "if", "unless", "case", "return", "raise", "puts", "p", "print", "require"]).freeze
|
|
11
|
+
# PERF: Hoist require and excluded call lists to frozen Sets to avoid per-node Array allocations in hot AST walks.
|
|
12
|
+
REQUIRE_CALLS = Set.new(["require", "require_relative"]).freeze
|
|
13
|
+
EXCLUDED_PRISM_CALLS = Set.new(["require", "require_relative", "raise", "puts", "p", "print"]).freeze
|
|
14
|
+
|
|
11
15
|
begin
|
|
12
16
|
require "prism"
|
|
13
17
|
PRISM_AVAILABLE = true
|
|
@@ -119,7 +123,8 @@ module Rubik
|
|
|
119
123
|
|
|
120
124
|
when Prism::CallNode
|
|
121
125
|
name = node.name.to_s
|
|
122
|
-
|
|
126
|
+
# PERF: Fast Set lookup avoids Array allocation on every CallNode
|
|
127
|
+
if REQUIRE_CALLS.include?(name)
|
|
123
128
|
line_num = node.location.start_line
|
|
124
129
|
args_node = node.arguments&.arguments
|
|
125
130
|
arg = args_node&.first&.slice.to_s
|
|
@@ -153,7 +158,8 @@ module Rubik
|
|
|
153
158
|
operator = curr.call_operator || "."
|
|
154
159
|
cname = "#{receiver}#{operator}#{cname}"
|
|
155
160
|
end
|
|
156
|
-
|
|
161
|
+
# PERF: Fast Set lookup avoids Array allocation per call site in method body
|
|
162
|
+
calls << cname unless EXCLUDED_PRISM_CALLS.include?(cname)
|
|
157
163
|
end
|
|
158
164
|
queue.concat(curr.compact_child_nodes) if curr.respond_to?(:compact_child_nodes)
|
|
159
165
|
end
|
|
@@ -38,6 +38,12 @@ module Rubik
|
|
|
38
38
|
lines << " • Failed: #{failure.failed}#{cause ? " (#{cause.class}: #{cause.message})" : " (#{failure.kind})"}"
|
|
39
39
|
lines << " • Still dirty — a retry writes only these: #{failure.pending.join(', ')}" unless failure.pending.empty?
|
|
40
40
|
lines << " • Skipped conflicts (disk changed externally): #{failure.conflicts.join(', ')}" unless failure.conflicts.empty?
|
|
41
|
+
if failure.respond_to?(:skipped) && !failure.skipped.empty?
|
|
42
|
+
skipped_desc = failure.skipped.map do |s|
|
|
43
|
+
s.is_a?(Hash) ? "#{s[:path]} (#{s[:reason]})" : s.to_s
|
|
44
|
+
end.join(", ")
|
|
45
|
+
lines << " • Skipped invalid paths (anti-injection/garbage): #{skipped_desc}"
|
|
46
|
+
end
|
|
41
47
|
if sync
|
|
42
48
|
annotation = sync_annotation(failure, sync)
|
|
43
49
|
lines << annotation if annotation
|
data/lib/rubik/buffer/manager.rb
CHANGED
|
@@ -21,20 +21,22 @@ module Rubik
|
|
|
21
21
|
# the underlying error. `pending` lists this run's entries that are
|
|
22
22
|
# still dirty (a retry writes only those).
|
|
23
23
|
class ApplyFailedError < StandardError
|
|
24
|
-
attr_reader :kind, :applied, :failed, :pending, :conflicts
|
|
24
|
+
attr_reader :kind, :applied, :failed, :pending, :conflicts, :skipped
|
|
25
25
|
|
|
26
|
-
def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [])
|
|
26
|
+
def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [], skipped: [])
|
|
27
27
|
super(message)
|
|
28
28
|
@kind = kind
|
|
29
29
|
@applied = applied
|
|
30
30
|
@failed = failed
|
|
31
31
|
@pending = pending
|
|
32
32
|
@conflicts = conflicts
|
|
33
|
+
@skipped = skipped
|
|
33
34
|
end
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
class Manager
|
|
37
|
-
attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs,
|
|
38
|
+
attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs,
|
|
39
|
+
:guard, :last_conflicts, :last_skipped
|
|
38
40
|
attr_accessor :allow_external_writes, :config
|
|
39
41
|
|
|
40
42
|
def initialize(root_dir: Dir.pwd, additional_roots: [], allowed_external_dirs: [], allow_external_writes: false,
|
|
@@ -52,7 +54,8 @@ module Rubik
|
|
|
52
54
|
@buffer = {}
|
|
53
55
|
@dirty_files = {}
|
|
54
56
|
@original_hashes = {}
|
|
55
|
-
@last_conflicts = []
|
|
57
|
+
@last_conflicts = [].freeze
|
|
58
|
+
@last_skipped = [].freeze
|
|
56
59
|
@last_applied = nil
|
|
57
60
|
end
|
|
58
61
|
|
|
@@ -317,25 +320,35 @@ module Rubik
|
|
|
317
320
|
@monitor.synchronize do
|
|
318
321
|
applied_paths = []
|
|
319
322
|
conflicts = []
|
|
323
|
+
skipped = []
|
|
320
324
|
keys_to_apply = determine_keys_to_apply(target_paths)
|
|
321
325
|
effective_policy = (policy || external_modification_policy).to_s.downcase
|
|
322
326
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
327
|
+
begin
|
|
328
|
+
keys_to_apply.each_with_index do |rel_path, index|
|
|
329
|
+
next unless @dirty_files.key?(rel_path)
|
|
326
330
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
end
|
|
331
|
+
unless validate_and_clean_garbage(rel_path)
|
|
332
|
+
skipped << { path: rel_path, reason: :path_validator_rejected }
|
|
333
|
+
next
|
|
334
|
+
end
|
|
332
335
|
|
|
333
|
-
|
|
336
|
+
if external_conflict?(rel_path) && effective_policy != "overwrite"
|
|
337
|
+
conflicts << rel_path
|
|
338
|
+
@event_bus&.emit(:buffer_apply_conflict, paths: conflicts.dup.freeze, path: rel_path)
|
|
339
|
+
next
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply,
|
|
343
|
+
applied_paths: applied_paths, conflicts: conflicts, skipped: skipped)
|
|
344
|
+
end
|
|
345
|
+
ensure
|
|
346
|
+
@last_conflicts = conflicts.dup.freeze
|
|
347
|
+
@last_skipped = skipped.dup.freeze
|
|
348
|
+
@last_applied = { at: Time.now.utc, paths: applied_paths.dup.freeze } unless applied_paths.empty?
|
|
334
349
|
end
|
|
335
350
|
|
|
336
|
-
@
|
|
337
|
-
@last_applied = { at: Time.now.utc, paths: applied_paths.dup.freeze } unless applied_paths.empty?
|
|
338
|
-
@event_bus&.emit(:buffer_applied, paths: applied_paths) unless applied_paths.empty?
|
|
351
|
+
@event_bus&.emit(:buffer_applied, paths: applied_paths.dup.freeze) unless applied_paths.empty?
|
|
339
352
|
applied_paths
|
|
340
353
|
end
|
|
341
354
|
end
|
|
@@ -353,7 +366,8 @@ module Rubik
|
|
|
353
366
|
@buffer.clear
|
|
354
367
|
@dirty_files.clear
|
|
355
368
|
@original_hashes.clear
|
|
356
|
-
@last_conflicts.
|
|
369
|
+
@last_conflicts = [].freeze
|
|
370
|
+
@last_skipped = [].freeze
|
|
357
371
|
@last_applied = nil
|
|
358
372
|
end
|
|
359
373
|
end
|
|
@@ -410,7 +424,7 @@ module Rubik
|
|
|
410
424
|
end
|
|
411
425
|
end
|
|
412
426
|
|
|
413
|
-
def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:)
|
|
427
|
+
def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:, skipped:)
|
|
414
428
|
full_path = File.expand_path(rel_path, @root_dir)
|
|
415
429
|
|
|
416
430
|
# TOCTOU close: re-verify containment immediately before the
|
|
@@ -422,15 +436,11 @@ module Rubik
|
|
|
422
436
|
containment_kind, = containment_root(full_path)
|
|
423
437
|
unless containment_kind
|
|
424
438
|
@event_bus&.emit(:buffer_containment_blocked, path: rel_path)
|
|
425
|
-
# Commit what this run has seen so "apply! rewrites
|
|
426
|
-
# last_conflicts on every run" holds for readers (AutoApply's
|
|
427
|
-
# report_conflicts) even on the failure path.
|
|
428
|
-
@last_conflicts = conflicts
|
|
429
439
|
raise apply_failure(
|
|
430
440
|
"Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
|
|
431
441
|
"(possible symlink swap since buffering). Re-verify the workspace and retry.",
|
|
432
442
|
kind: :containment_blocked, applied: applied_paths, failed: rel_path,
|
|
433
|
-
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
443
|
+
pending: pending_after(keys_to_apply, index), conflicts: conflicts, skipped: skipped
|
|
434
444
|
)
|
|
435
445
|
end
|
|
436
446
|
|
|
@@ -439,13 +449,12 @@ module Rubik
|
|
|
439
449
|
begin
|
|
440
450
|
Rubik::Platform.atomic_write(full_path, content)
|
|
441
451
|
rescue StandardError => e
|
|
442
|
-
@last_conflicts = conflicts
|
|
443
452
|
raise apply_failure(
|
|
444
453
|
"Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
|
|
445
454
|
"#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
|
|
446
455
|
"#{pending_after(keys_to_apply, index).size} file(s) still dirty",
|
|
447
456
|
kind: :write_failed, applied: applied_paths, failed: rel_path,
|
|
448
|
-
pending: pending_after(keys_to_apply, index), conflicts: conflicts
|
|
457
|
+
pending: pending_after(keys_to_apply, index), conflicts: conflicts, skipped: skipped
|
|
449
458
|
), cause: e
|
|
450
459
|
end
|
|
451
460
|
|
|
@@ -494,11 +503,12 @@ module Rubik
|
|
|
494
503
|
|
|
495
504
|
# Build the structured partial-apply error with frozen report copies —
|
|
496
505
|
# a reader must see the state at raise time, not later mutations.
|
|
497
|
-
def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:)
|
|
506
|
+
def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:, skipped: [])
|
|
498
507
|
ApplyFailedError.new(
|
|
499
508
|
message, kind: kind,
|
|
500
509
|
applied: applied.dup.freeze, failed: failed,
|
|
501
|
-
pending: pending.dup.freeze, conflicts: conflicts.dup.freeze
|
|
510
|
+
pending: pending.dup.freeze, conflicts: conflicts.dup.freeze,
|
|
511
|
+
skipped: skipped.dup.freeze
|
|
502
512
|
)
|
|
503
513
|
end
|
|
504
514
|
|
data/lib/rubik/mcp/client.rb
CHANGED
data/lib/rubik/mcp/manager.rb
CHANGED
|
@@ -51,6 +51,7 @@ module Rubik
|
|
|
51
51
|
# boot epoch that owns them (@boot_marks).
|
|
52
52
|
@stopped = false
|
|
53
53
|
@boot_thread = nil
|
|
54
|
+
@boot_slot = nil
|
|
54
55
|
@boot_marks = {}
|
|
55
56
|
@monitor = Monitor.new
|
|
56
57
|
end
|
|
@@ -68,18 +69,13 @@ module Rubik
|
|
|
68
69
|
name_str = name.to_s
|
|
69
70
|
parsed_defn = defn.is_a?(Hash) ? defn.transform_keys(&:to_s) : {}
|
|
70
71
|
persist_res = { ok: true }
|
|
71
|
-
tools_count = 0
|
|
72
72
|
|
|
73
73
|
@monitor.synchronize do
|
|
74
74
|
@servers[name_str] = parsed_defn
|
|
75
75
|
@statuses[name_str] ||= :disabled
|
|
76
76
|
|
|
77
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
|
-
}
|
|
78
|
+
server_hash = { "command" => parsed_defn["command"], "args" => Array(parsed_defn["args"]), "env" => parsed_defn["env"] || {} }
|
|
83
79
|
# Per-server handshake override round-trips only when present,
|
|
84
80
|
# so `!mcp add` output stays minimal (the knob is hand-written
|
|
85
81
|
# config, not a CLI flag).
|
|
@@ -87,14 +83,12 @@ module Rubik
|
|
|
87
83
|
persist_res = @config.set("mcp.servers.#{name_str}", server_hash, persist: persist)
|
|
88
84
|
end
|
|
89
85
|
|
|
90
|
-
|
|
91
|
-
enable_server(name_str)
|
|
92
|
-
else
|
|
93
|
-
transition_server!(name_str, :disabled)
|
|
94
|
-
end
|
|
95
|
-
tools_count = (@tools[name_str] || []).size
|
|
86
|
+
transition_server!(name_str, :disabled) unless auto_start && enabled?
|
|
96
87
|
end
|
|
97
88
|
|
|
89
|
+
enable_server(name_str) if auto_start && enabled?
|
|
90
|
+
|
|
91
|
+
tools_count = @monitor.synchronize { (@tools[name_str] || []).size }
|
|
98
92
|
persist_res.merge(tools: tools_count)
|
|
99
93
|
end
|
|
100
94
|
|
|
@@ -219,30 +213,42 @@ module Rubik
|
|
|
219
213
|
# Post-wiring failures stopped the client via the ctx drain above;
|
|
220
214
|
# pre-wiring failures (stale-context dispose, ctx.extend) reach
|
|
221
215
|
# only this line — idempotent Client#stop makes it exactly-once.
|
|
222
|
-
|
|
223
|
-
client.stop
|
|
224
|
-
rescue StandardError
|
|
225
|
-
nil
|
|
226
|
-
end
|
|
216
|
+
safely_stop_rolled_back_client(client, name_str)
|
|
227
217
|
raise e
|
|
228
218
|
end
|
|
229
219
|
end
|
|
230
220
|
|
|
231
221
|
def enable_server(name)
|
|
232
222
|
name_str = name.to_s
|
|
223
|
+
server_defn = nil
|
|
224
|
+
epoch = nil
|
|
233
225
|
@monitor.synchronize do
|
|
234
226
|
server_defn = @servers[name_str]
|
|
235
227
|
raise Rubik::MCP::Error, "MCP server '#{name_str}' not found" unless server_defn
|
|
236
228
|
|
|
237
229
|
return @clients[name_str] if @statuses[name_str] == :active && @clients[name_str]&.running?
|
|
238
230
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
231
|
+
epoch = Object.new
|
|
232
|
+
transition_server!(name_str, :connecting)
|
|
233
|
+
@boot_marks[name_str] = epoch
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
connected = begin
|
|
237
|
+
connect_server(name_str, server_defn)
|
|
238
|
+
rescue StandardError => e
|
|
239
|
+
@monitor.synchronize { transition_server!(name_str, :failed, epoch: epoch) }
|
|
240
|
+
raise e
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
@monitor.synchronize do
|
|
244
|
+
if @stopped || @statuses[name_str] != :connecting || !@boot_marks[name_str].equal?(epoch)
|
|
245
|
+
safely_stop_rolled_back_client(connected[:client], name_str)
|
|
246
|
+
return @clients[name_str]
|
|
244
247
|
end
|
|
248
|
+
|
|
245
249
|
apply_connected_server(name_str, server_defn, **connected)
|
|
250
|
+
transition_server!(name_str, :active, epoch: epoch)
|
|
251
|
+
@clients[name_str]
|
|
246
252
|
end
|
|
247
253
|
end
|
|
248
254
|
|
|
@@ -301,18 +307,29 @@ module Rubik
|
|
|
301
307
|
end
|
|
302
308
|
|
|
303
309
|
join_boot_safely(2.0)
|
|
310
|
+
epoch = Object.new
|
|
304
311
|
@monitor.synchronize { @stopped = false }
|
|
305
312
|
thread = Thread.new do
|
|
306
313
|
perform_load_from_config(cfg)
|
|
307
314
|
rescue StandardError, Interrupt => e
|
|
308
315
|
Rubik::Log.warn(:mcp, "background MCP boot failed (#{e.class}: #{e.message})")
|
|
309
316
|
ensure
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
@monitor.synchronize
|
|
317
|
+
# Epoch-tagged clear: only clear OUR reference so a wedged boot A
|
|
318
|
+
# expiring past the drain bound cannot clobber a newer boot B's slot.
|
|
319
|
+
@monitor.synchronize do
|
|
320
|
+
@boot_slot = @boot_thread = nil if @boot_slot && @boot_slot[:epoch].equal?(epoch)
|
|
321
|
+
end
|
|
313
322
|
end
|
|
314
323
|
thread.name = "rubik-mcp-boot" if thread.respond_to?(:name=)
|
|
315
|
-
@
|
|
324
|
+
@monitor.synchronize do
|
|
325
|
+
if thread.alive?
|
|
326
|
+
@boot_slot = { thread: thread, epoch: epoch }
|
|
327
|
+
@boot_thread = thread
|
|
328
|
+
elsif @boot_slot && @boot_slot[:epoch].equal?(epoch)
|
|
329
|
+
@boot_slot = @boot_thread = nil
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
thread
|
|
316
333
|
end
|
|
317
334
|
|
|
318
335
|
private
|
|
@@ -509,7 +526,6 @@ module Rubik
|
|
|
509
526
|
disable_server(name)
|
|
510
527
|
rescue StandardError => e
|
|
511
528
|
Rubik::Log.warn(:mcp, "failed to stop MCP server '#{name}' during teardown (#{e.class}: #{e.message})")
|
|
512
|
-
nil
|
|
513
529
|
end
|
|
514
530
|
end
|
|
515
531
|
end
|
|
@@ -517,11 +533,7 @@ module Rubik
|
|
|
517
533
|
alias dispose stop_all
|
|
518
534
|
|
|
519
535
|
def status_summary
|
|
520
|
-
@monitor.synchronize
|
|
521
|
-
@servers.map do |name, _defn|
|
|
522
|
-
server_status(name)
|
|
523
|
-
end.compact
|
|
524
|
-
end
|
|
536
|
+
@monitor.synchronize { @servers.keys.map { |name| server_status(name) }.compact }
|
|
525
537
|
end
|
|
526
538
|
|
|
527
539
|
def server_status(name)
|
|
@@ -602,9 +614,7 @@ module Rubik
|
|
|
602
614
|
# falls through to the next source — a miswritten knob must never fail
|
|
603
615
|
# the boot at the config boundary.
|
|
604
616
|
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?
|
|
617
|
+
sources = [explicit, server_defn["timeout"] || server_defn[:timeout], @config&.fetch("mcp.timeout", nil)]
|
|
608
618
|
|
|
609
619
|
sources.each do |candidate|
|
|
610
620
|
return candidate if candidate.is_a?(Numeric) && candidate.positive?
|
|
@@ -614,6 +624,12 @@ module Rubik
|
|
|
614
624
|
end
|
|
615
625
|
Client::DEFAULT_TIMEOUT
|
|
616
626
|
end
|
|
627
|
+
|
|
628
|
+
def safely_stop_rolled_back_client(client, name)
|
|
629
|
+
client&.stop
|
|
630
|
+
rescue StandardError => e
|
|
631
|
+
Rubik::Log.warn(:mcp, "failed to stop rolled-back MCP client '#{name}' (#{e.class}: #{e.message})")
|
|
632
|
+
end
|
|
617
633
|
end
|
|
618
634
|
end
|
|
619
635
|
end
|