rubik 0.1.5 → 0.2.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 +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +130 -0
- data/README.md +30 -17
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/orchestrator/step_executor.rb +9 -2
- data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
- data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
- data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
- data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
- data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
- data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
- data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
- data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
- data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
- data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
- data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
- data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
- data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
- data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
- data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
- data/lib/rubik/orchestrator/tool_history.rb +8 -3
- data/lib/rubik/platform/process.rb +74 -2
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- data/lib/rubik/repl/commands/subagents.rb +2 -0
- data/lib/rubik/repl/input_execution.rb +42 -0
- data/lib/rubik/repl/input_loop.rb +57 -7
- data/lib/rubik/repl/prompt_grace.rb +103 -0
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +115 -6
- data/lib/rubik/security/redactor.rb +7 -7
- data/lib/rubik/storage/transcript_indexer.rb +21 -11
- data/lib/rubik/tools/builtins/exec.rb +76 -28
- data/lib/rubik/tools/builtins/files.rb +6 -1
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/tools/builtins/subagent.rb +39 -31
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
- data/lib/rubik/tools/truncator.rb +19 -2
- data/lib/rubik/tools/uri_resolver.rb +84 -20
- data/lib/rubik/uris.rb +11 -0
- data/lib/rubik/version.rb +1 -1
- metadata +20 -1
|
@@ -23,6 +23,9 @@ module Rubik
|
|
|
23
23
|
# PERF: Pre-compiled constants to avoid re-allocation inside hot loops
|
|
24
24
|
URL_PATTERN = %r{(https?://[^:]+:)([^@]+)@}
|
|
25
25
|
URL_MASK = "\\1#{MASK_STRING}@".freeze
|
|
26
|
+
STATIC_PATTERNS = PATTERNS.reject { |pattern| pattern == URL_PATTERN }.freeze
|
|
27
|
+
# Skip static replacement passes for assignment-only and URL-only inputs.
|
|
28
|
+
STATIC_PATTERNS_COMBINED_REGEX = Regexp.union(STATIC_PATTERNS).freeze
|
|
26
29
|
QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^:]+:|password|passwd|api_key|secret|token}i
|
|
27
30
|
|
|
28
31
|
# Anahtar-maskeleme desenleri (Session::Recorder#mask_payload ile aynı
|
|
@@ -46,13 +49,10 @@ module Rubik
|
|
|
46
49
|
# ve desenler zaten bu metne uygulanıyordu, dolayısıyla eşdeğerdir.
|
|
47
50
|
return result unless result.match?(QUICK_CHECK_REGEX)
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
result.gsub!(pattern, MASK_STRING)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
52
|
+
# Keep replacement order: masking one credential can expose a word
|
|
53
|
+
# boundary needed to recognize the next (for example, a bearer token).
|
|
54
|
+
STATIC_PATTERNS.each { |pattern| result.gsub!(pattern, MASK_STRING) } if result.match?(STATIC_PATTERNS_COMBINED_REGEX)
|
|
55
|
+
result.gsub!(URL_PATTERN, URL_MASK)
|
|
56
56
|
|
|
57
57
|
result.gsub!(ASSIGNMENT_KEY_REGEX) do
|
|
58
58
|
"#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{MASK_STRING}"
|
|
@@ -26,23 +26,30 @@ module Rubik
|
|
|
26
26
|
return unless store && inst
|
|
27
27
|
|
|
28
28
|
file = inst.transcript_uri.to_s
|
|
29
|
+
result_ref = inst.respond_to?(:result_ref) ? inst.result_ref : nil
|
|
29
30
|
# Gerçek upsert: alias üzerinden varsa mevcut id güncellenir — resume
|
|
30
31
|
# sonrası ikinci tamamlanma yinelenen transcript varlığı yaratmaz.
|
|
31
32
|
ref = Rubik::Uris.transcript_ref(inst.storage_id)
|
|
32
33
|
existing = store.get(type: "transcript", id: ref)
|
|
34
|
+
links = {
|
|
35
|
+
"subagent_id" => inst.storage_id.to_s,
|
|
36
|
+
# The raw path is forensics-only (read_file denies it): the
|
|
37
|
+
# canonical read route rides in the entity itself so recall
|
|
38
|
+
# consumers land on show_file in one hop (issue #97 finding 5).
|
|
39
|
+
"canonical" => Rubik::Uris.subagent_transcript(inst.storage_id),
|
|
40
|
+
"transcript" => file
|
|
41
|
+
}
|
|
42
|
+
# Issue #122: the searchable summary also points at the paginated
|
|
43
|
+
# retained-result route. Availability is session-scoped (archive TTL),
|
|
44
|
+
# so this is a pointer with explicit expiration semantics, not a
|
|
45
|
+
# promise of unbounded retention.
|
|
46
|
+
links["result"] = result_ref if result_ref
|
|
33
47
|
store.put(
|
|
34
48
|
type: "transcript",
|
|
35
49
|
title: "[#{inst.type_name}] #{inst.role} (#{inst.id}) — #{inst.state}",
|
|
36
|
-
body: entity_body(inst, file),
|
|
50
|
+
body: entity_body(inst, file, result_ref: result_ref),
|
|
37
51
|
tags: [inst.type_name.to_s, inst.state.to_s].uniq,
|
|
38
|
-
links:
|
|
39
|
-
"subagent_id" => inst.storage_id.to_s,
|
|
40
|
-
# The raw path is forensics-only (read_file denies it): the
|
|
41
|
-
# canonical read route rides in the entity itself so recall
|
|
42
|
-
# consumers land on show_file in one hop (issue #97 finding 5).
|
|
43
|
-
"canonical" => Rubik::Uris.subagent_transcript(inst.storage_id),
|
|
44
|
-
"transcript" => file
|
|
45
|
-
},
|
|
52
|
+
links: links,
|
|
46
53
|
status: inst.state.to_s,
|
|
47
54
|
id: existing&.[]("id"),
|
|
48
55
|
ref: ref
|
|
@@ -59,7 +66,7 @@ module Rubik
|
|
|
59
66
|
# gelmeyen her metin (prompt fallback, inst.result) HAM memory'den
|
|
60
67
|
# gelir — kalıcı+aranabilir gövdeye girmeden Redactor'dan geçer
|
|
61
68
|
# (dosya tarafının maskeleme paritesi; review bulgusu).
|
|
62
|
-
def entity_body(inst, file)
|
|
69
|
+
def entity_body(inst, file, result_ref: nil)
|
|
63
70
|
task = first_user_task(file)
|
|
64
71
|
task_part = if task
|
|
65
72
|
"[task] #{clip(task)}"
|
|
@@ -71,7 +78,10 @@ module Rubik
|
|
|
71
78
|
|
|
72
79
|
result = inst.respond_to?(:result) ? redact(inst.result.to_s) : ""
|
|
73
80
|
result_part = result.empty? ? "[state] #{inst.state} — no result recorded" : "[result] #{clip(result)}"
|
|
74
|
-
"#{task_part}\n#{result_part}"
|
|
81
|
+
body = "#{task_part}\n#{result_part}"
|
|
82
|
+
return body unless result_ref && inst.respond_to?(:state) && inst.state.to_s == "completed"
|
|
83
|
+
|
|
84
|
+
"#{body}\n[retrieve] #{result_ref} — full retained result, paginated (manage_subagents action 'result')"
|
|
75
85
|
end
|
|
76
86
|
|
|
77
87
|
def redact(text)
|
|
@@ -150,51 +150,99 @@ module Rubik
|
|
|
150
150
|
def execute(args = {})
|
|
151
151
|
cmd = (args["command"] || args[:command]).to_s.strip
|
|
152
152
|
background = args["background"] || args[:background]
|
|
153
|
-
if background
|
|
154
|
-
task_mgr = @task_manager || (@runtime_context.respond_to?(:get) ? Rubik::Coeffects.get(@runtime_context, Rubik::Coeffects::TASK_MANAGER) : nil)
|
|
155
|
-
return ToolOutput.error("TaskManager is not available to run commands in background.") unless task_mgr
|
|
153
|
+
return execute_background(cmd) if background
|
|
156
154
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
timeout = effective_timeout(args)
|
|
156
|
+
@guard.verify_command_safety!(cmd)
|
|
157
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
158
|
+
res = @runner.run(cmd, timeout: timeout, chdir: @root_dir)
|
|
159
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
|
|
160
|
+
command_result(cmd, res, duration_ms: duration_ms)
|
|
161
|
+
rescue Rubik::Security::BlockedWriteError => e
|
|
162
|
+
# Authorization denial (issue #127): nothing executed, so no process
|
|
163
|
+
# exit code is fabricated — the metadata says denied / not executed.
|
|
164
|
+
# The prefix stays first for display consumers (StepExecutor::ERROR_PREFIXES).
|
|
165
|
+
Rubik::Tools::Result.new(
|
|
166
|
+
tool_name: "run_command", status: :error,
|
|
167
|
+
output: Rubik::Tools::RunCommandTool.blocked_guidance(e),
|
|
168
|
+
error: "command denied by security policy (not executed)",
|
|
169
|
+
exit_code: nil, metadata: { "executed" => false, "denied" => "authorization" }
|
|
170
|
+
)
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
Rubik::Tools::Result.new(
|
|
173
|
+
tool_name: "run_command", status: :error,
|
|
174
|
+
output: "Command error: #{e.message}", error: e.message,
|
|
175
|
+
exit_code: nil, metadata: { "executed" => false }
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Stable public seam (RunCommandTool#execute, ManageTaskTool, tests and
|
|
180
|
+
# dev/review_fix_check call it). The renderer lives in the security
|
|
181
|
+
# domain next to the error it renders: Rubik::Security::BlockedGuidance
|
|
182
|
+
# — also keeps this file under the builtin line budget.
|
|
183
|
+
def self.blocked_guidance(err)
|
|
184
|
+
Rubik::Security::BlockedGuidance.render(err)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def execute_background(cmd)
|
|
190
|
+
task_mgr = @task_manager ||
|
|
191
|
+
(@runtime_context.respond_to?(:get) ? Rubik::Coeffects.get(@runtime_context, Rubik::Coeffects::TASK_MANAGER) : nil)
|
|
192
|
+
if task_mgr.nil?
|
|
193
|
+
return Rubik::Tools::Result.new(tool_name: "run_command", status: :error,
|
|
194
|
+
output: "TaskManager is not available to run commands in background.",
|
|
195
|
+
error: "task manager unavailable",
|
|
196
|
+
metadata: { "executed" => false, "background" => true })
|
|
160
197
|
end
|
|
161
198
|
|
|
199
|
+
@guard.verify_command_safety!(cmd)
|
|
200
|
+
task = task_mgr.start(cmd, chdir: @root_dir, ctx: @runtime_context)
|
|
201
|
+
text = "Command dispatched to background task [#{task.id}] (PID #{task.pid}): #{cmd}\n" \
|
|
202
|
+
"Use `manage_task` with action 'status' or 'logs' to inspect."
|
|
203
|
+
Rubik::Tools::Result.new(tool_name: "run_command", status: :ok, output: text, exit_code: nil,
|
|
204
|
+
metadata: { "executed" => true, "background" => true, "task_id" => task.id })
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def effective_timeout(args)
|
|
162
208
|
# Timeout contract: runners expose their config via a public `config`
|
|
163
209
|
# reader (Sandbox::Runner does via attr_reader). Never reach into an
|
|
164
210
|
# arbitrary object with instance_variable_get — that couples us to an
|
|
165
211
|
# ivar name we do not own and silently falls back if it is ever renamed.
|
|
166
212
|
cfg = @runner.config if @runner.respond_to?(:config)
|
|
167
213
|
cfg_timeout = cfg&.fetch("tools.timeout_seconds", 30) || 30
|
|
168
|
-
|
|
214
|
+
(args["timeout"] || args[:timeout] || cfg_timeout).to_i.clamp(1, 600)
|
|
215
|
+
end
|
|
169
216
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
217
|
+
# The structured command outcome (issue #127): the display text keeps
|
|
218
|
+
# the historical [OK]/[EXIT n] prefix and diagnostics, while the machine
|
|
219
|
+
# fields (status / exit_code / truncated / metadata) ride the Result
|
|
220
|
+
# contract through BOTH dispatch paths — no display-text parsing.
|
|
221
|
+
def command_result(cmd, res, duration_ms:)
|
|
222
|
+
exit_code = res[:exit_code]
|
|
223
|
+
timed_out = exit_code.to_i == 124
|
|
224
|
+
status_text = res[:ok] ? "OK" : "EXIT #{exit_code}"
|
|
175
225
|
shell_tag = res[:shell] ? " (#{res[:shell]})" : ""
|
|
176
|
-
out = "[#{
|
|
226
|
+
out = "[#{status_text}]#{shell_tag} #{res[:output]}"
|
|
177
227
|
out += "\n#{res[:isolation]}" if res[:isolation]
|
|
178
228
|
|
|
229
|
+
diagnostic = nil
|
|
179
230
|
unless res[:ok]
|
|
180
231
|
require_relative "../../runtime/diagnostic_engine" unless defined?(Rubik::Runtime::DiagnosticEngine)
|
|
181
|
-
|
|
182
|
-
out += "\n\n#{
|
|
232
|
+
diagnostic = Rubik::Runtime::DiagnosticEngine.analyze_process_result(res, cmd: cmd)
|
|
233
|
+
out += "\n\n#{diagnostic.format_for_llm}" if diagnostic
|
|
183
234
|
end
|
|
184
|
-
out
|
|
185
|
-
rescue Rubik::Security::BlockedWriteError => e
|
|
186
|
-
# Prefix stays first: StepExecutor::ERROR_PREFIXES classifies by start_with?.
|
|
187
|
-
ToolOutput.error(Rubik::Tools::RunCommandTool.blocked_guidance(e))
|
|
188
|
-
rescue StandardError => e
|
|
189
|
-
ToolOutput.error("Command error: #{e.message}")
|
|
190
|
-
end
|
|
191
235
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
236
|
+
timeout_suffix = timed_out ? " (timed out)" : ""
|
|
237
|
+
error_message = res[:ok] ? nil : "command exited with status #{exit_code}#{timeout_suffix}"
|
|
238
|
+
|
|
239
|
+
Rubik::Tools::Result.new(
|
|
240
|
+
tool_name: "run_command", status: res[:ok] ? :ok : :error, output: out,
|
|
241
|
+
error: error_message, exit_code: exit_code, duration_ms: duration_ms,
|
|
242
|
+
metadata: { "executed" => true, "timed_out" => timed_out,
|
|
243
|
+
"diagnostic_category" => diagnostic && diagnostic.category.to_s,
|
|
244
|
+
"shell" => res[:shell].to_s, "driver" => res[:driver].to_s }.reject { |_k, v| v.nil? || v == "" }
|
|
245
|
+
)
|
|
198
246
|
end
|
|
199
247
|
end
|
|
200
248
|
|
|
@@ -19,7 +19,12 @@ module Rubik
|
|
|
19
19
|
type: "object",
|
|
20
20
|
properties: {
|
|
21
21
|
path: { type: "string", description: "File path, or virtual rubik:// URI: 'rubik://storage/<project>/<type>/<id>' " \
|
|
22
|
-
"(browse from 'rubik://storage/index'), 'rubik://goal/state',
|
|
22
|
+
"(browse from 'rubik://storage/index'), 'rubik://goal/state', " \
|
|
23
|
+
"'rubik://subagent/<storage-id>/transcript' (the UNIQUE storage id from " \
|
|
24
|
+
"manage_subagents status 'transcript_ref' or wait rows — live while running, " \
|
|
25
|
+
"persisted after the first save; a display id like 'subagent-2' or a disk path " \
|
|
26
|
+
"is NOT a transcript address), 'rubik://subagent/<storage-id>/result[/<run>]' " \
|
|
27
|
+
"(retained completed results)" },
|
|
23
28
|
offset: { type: "integer", description: "1-based line number to start reading from (optional)" },
|
|
24
29
|
limit: { type: "integer", description: "Maximum number of lines to read (optional)" },
|
|
25
30
|
from_disk: { type: "boolean", description: "Read current disk content without modifying in-memory buffer" },
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "../base"
|
|
4
4
|
require_relative "../output"
|
|
5
5
|
require_relative "../../runtime"
|
|
6
|
+
require_relative "../../runtime/repair_probe"
|
|
7
|
+
require_relative "../../buffer/apply_contract"
|
|
6
8
|
|
|
7
9
|
module Rubik
|
|
8
10
|
module Tools
|
|
@@ -33,7 +35,9 @@ module Rubik
|
|
|
33
35
|
def execute(args = {})
|
|
34
36
|
return ToolOutput.error("Buffer manager is not connected.") unless @buffer_manager
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
shell_config = @shell.respond_to?(:config) ? @shell.config : nil
|
|
39
|
+
manager_config = @buffer_manager.respond_to?(:config) ? @buffer_manager.config : nil
|
|
40
|
+
unless Rubik::Buffer::ApplyContract.model_can_apply?(shell_config: shell_config, manager_config: manager_config)
|
|
37
41
|
return ToolOutput.error("Model buffer application is disabled by configuration " \
|
|
38
42
|
"(buffer.model_can_apply: false). The human operator will review and apply changes.")
|
|
39
43
|
end
|
|
@@ -43,7 +47,15 @@ module Rubik
|
|
|
43
47
|
policy = force ? "overwrite" : nil
|
|
44
48
|
|
|
45
49
|
if @buffer_manager.dirty?
|
|
46
|
-
applied =
|
|
50
|
+
applied = nil
|
|
51
|
+
begin
|
|
52
|
+
applied = @buffer_manager.apply!(policy: policy)
|
|
53
|
+
rescue Rubik::Buffer::ApplyFailedError => e
|
|
54
|
+
# Partial apply: files in e.applied ARE on disk — sync the model's
|
|
55
|
+
# view for them and report the remainder honestly (issue #146).
|
|
56
|
+
lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { @shell&.sync_context_and_ast(paths) })
|
|
57
|
+
return ToolOutput.error(lines.join("\n"))
|
|
58
|
+
end
|
|
47
59
|
conflicts = @buffer_manager.last_conflicts || []
|
|
48
60
|
@shell&.sync_context_and_ast(applied) unless applied.empty?
|
|
49
61
|
|
|
@@ -68,30 +80,16 @@ module Rubik
|
|
|
68
80
|
messages.join("\n")
|
|
69
81
|
end
|
|
70
82
|
else
|
|
71
|
-
clean_buffer_message
|
|
83
|
+
Rubik::Buffer::ApplyContract.clean_buffer_message(@buffer_manager.last_applied, auto_apply_mode)
|
|
72
84
|
end
|
|
73
85
|
end
|
|
74
86
|
|
|
75
87
|
private
|
|
76
88
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# (issue #97 finding 6). When the mode cannot be read, the origin stays
|
|
82
|
-
# deliberately two-sided rather than inventing a specific cause.
|
|
83
|
-
def clean_buffer_message
|
|
84
|
-
last = @buffer_manager.last_applied
|
|
85
|
-
return "No unapplied buffer changes to write (buffer is clean)." unless last.is_a?(Hash) && Array(last[:paths]).any?
|
|
86
|
-
|
|
87
|
-
mode = @shell.respond_to?(:config) && @shell.config.respond_to?(:fetch) ? @shell.config.fetch("buffer.auto_apply", "manual") : nil
|
|
88
|
-
origin = if !mode.nil? && !mode.to_s.strip.empty? && mode.to_s.downcase != "manual"
|
|
89
|
-
"the auto-apply policy (buffer.auto_apply: #{mode})"
|
|
90
|
-
else
|
|
91
|
-
"an earlier apply_buffer call or the auto-apply policy"
|
|
92
|
-
end
|
|
93
|
-
"No unapplied buffer changes to write (buffer is clean): #{last[:paths].size} file(s) were already written to disk " \
|
|
94
|
-
"at #{last[:at].utc.iso8601} by #{origin}: #{last[:paths].join(', ')}."
|
|
89
|
+
def auto_apply_mode
|
|
90
|
+
return nil unless @shell.respond_to?(:config) && @shell.config.respond_to?(:fetch)
|
|
91
|
+
|
|
92
|
+
@shell.config.fetch("buffer.auto_apply", "manual")
|
|
95
93
|
end
|
|
96
94
|
end
|
|
97
95
|
|
|
@@ -117,6 +115,10 @@ module Rubik
|
|
|
117
115
|
type: "string",
|
|
118
116
|
description: "Replacement or custom command string (optional, probed automatically if auto_probe: true)"
|
|
119
117
|
},
|
|
118
|
+
probe_args: {
|
|
119
|
+
type: "array", items: { type: "string" },
|
|
120
|
+
description: "Explicit non-mutating functional probe arguments appended to the exact proposed command; required for unknown executables"
|
|
121
|
+
},
|
|
120
122
|
auto_probe: {
|
|
121
123
|
type: "boolean",
|
|
122
124
|
description: "Whether to automatically probe system binaries and candidate commands (default: true)"
|
|
@@ -187,7 +189,7 @@ module Rubik
|
|
|
187
189
|
error_diagnostic = nil
|
|
188
190
|
|
|
189
191
|
begin
|
|
190
|
-
verified, error_diagnostic = verify_binary(target, lang, proposed_cmd, sub_ctx)
|
|
192
|
+
verified, error_diagnostic = verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: args["probe_args"] || args[:probe_args])
|
|
191
193
|
rescue StandardError => e
|
|
192
194
|
error_diagnostic = Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
193
195
|
cmd: proposed_cmd,
|
|
@@ -200,66 +202,114 @@ module Rubik
|
|
|
200
202
|
|
|
201
203
|
return ToolOutput.error("Verification failed for #{target} (#{lang}):\n#{error_diagnostic&.format_for_llm}") unless verified
|
|
202
204
|
|
|
203
|
-
#
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
205
|
+
# Live changes belong to a disposable transaction. Successful project
|
|
206
|
+
# persistence is an operator decision and outlives lifecycle cleanup.
|
|
207
|
+
promotion_ctx = root_ctx.extend
|
|
208
|
+
released = false
|
|
209
|
+
@repair_scopes ||= {}
|
|
210
|
+
cfg_key = { "lsp" => "lsp.servers", "test_runner" => "test.runners",
|
|
211
|
+
"linter" => "linter.commands" }[target]
|
|
212
|
+
cfg_key = "#{cfg_key}.#{lang}" if cfg_key
|
|
213
|
+
prev_cmd = @config&.fetch(cfg_key) if cfg_key
|
|
214
|
+
service_key = "#{{ 'test_runner' => 'runner' }.fetch(target, target)}:#{lang}"
|
|
215
|
+
previous_services = root_ctx.services
|
|
216
|
+
phase = "Promotion"
|
|
217
|
+
begin
|
|
218
|
+
promotion_ctx.on_dispose do
|
|
219
|
+
next if released
|
|
220
|
+
|
|
221
|
+
@repair_scopes.delete([root_ctx, service_key]) if @repair_scopes[[root_ctx, service_key]].equal?(promotion_ctx)
|
|
222
|
+
unless root_ctx.disposed?
|
|
223
|
+
if previous_services.key?(service_key)
|
|
224
|
+
restored = target == "lsp" ? @lsp_manager&.clients&.[](lang) : nil
|
|
225
|
+
root_ctx.provide(service_key, restored || previous_services[service_key])
|
|
226
|
+
else
|
|
227
|
+
root_ctx.unprovide(service_key)
|
|
228
|
+
end
|
|
223
229
|
end
|
|
224
|
-
active_client = @lsp_manager.client_for(lang)
|
|
225
|
-
root_ctx.provide("lsp:#{lang}", active_client || proposed_cmd)
|
|
226
|
-
else
|
|
227
|
-
root_ctx.provide("lsp:#{lang}", proposed_cmd)
|
|
228
230
|
end
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
adapter = @adapter_registry.get(lang)
|
|
235
|
-
if adapter
|
|
236
|
-
adapter.config ||= @config
|
|
237
|
-
adapter.custom_test_cmd = proposed_cmd
|
|
238
|
-
else
|
|
239
|
-
require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
|
|
240
|
-
new_adapter = Rubik::Adapter::GenericAdapter.new(lang, test_cmd: proposed_cmd, guard: @guard)
|
|
241
|
-
new_adapter.config = @config
|
|
242
|
-
@adapter_registry.register(lang, new_adapter, ctx: root_ctx)
|
|
231
|
+
if cfg_key && @config
|
|
232
|
+
promotion_ctx.on_dispose do
|
|
233
|
+
next if released
|
|
234
|
+
|
|
235
|
+
prev_cmd.nil? ? @config.delete(cfg_key) : @config.set(cfg_key, prev_cmd)
|
|
243
236
|
end
|
|
237
|
+
@config.set(cfg_key, proposed_cmd)
|
|
244
238
|
end
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
@
|
|
239
|
+
|
|
240
|
+
case target
|
|
241
|
+
when "lsp"
|
|
242
|
+
raise "LSP manager and config are required for live activation" unless @lsp_manager && @config
|
|
243
|
+
|
|
244
|
+
had_client = !@lsp_manager.clients[lang].nil?
|
|
245
|
+
# The manager owns its client table. Restore the old configured
|
|
246
|
+
# client on rollback; probe clients never enter that table.
|
|
247
|
+
promotion_ctx.on_dispose do
|
|
248
|
+
next if released
|
|
249
|
+
|
|
250
|
+
@lsp_manager.stop_client(lang)
|
|
251
|
+
prev_cmd.nil? ? @config.delete(cfg_key) : @config.set(cfg_key, prev_cmd)
|
|
252
|
+
if had_client && !root_ctx.disposed?
|
|
253
|
+
restored = @lsp_manager.start_client(lang)
|
|
254
|
+
raise "Previous LSP client could not be restarted: #{restored}" unless restored[:ok]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
@lsp_manager.stop_client(lang)
|
|
258
|
+
start_res = @lsp_manager.start_client(lang)
|
|
259
|
+
raise "Failed to start LSP server: #{start_res}" unless start_res.is_a?(Hash) && start_res[:ok]
|
|
260
|
+
|
|
261
|
+
service = @lsp_manager.clients[lang]
|
|
262
|
+
raise "LSP manager reported success without an active client" unless service
|
|
263
|
+
when "test_runner", "linter"
|
|
264
|
+
if @adapter_registry
|
|
265
|
+
adapter = @adapter_registry.get(lang)
|
|
266
|
+
if adapter
|
|
267
|
+
field = target == "test_runner" ? :custom_test_cmd : :custom_format_cmd
|
|
268
|
+
old_value = adapter.public_send(field)
|
|
269
|
+
old_config = adapter.config
|
|
270
|
+
promotion_ctx.on_dispose do
|
|
271
|
+
next if released
|
|
272
|
+
|
|
273
|
+
adapter.public_send("#{field}=", old_value)
|
|
274
|
+
adapter.config = old_config
|
|
275
|
+
end
|
|
276
|
+
adapter.config ||= @config
|
|
277
|
+
adapter.public_send("#{field}=", proposed_cmd)
|
|
278
|
+
else
|
|
279
|
+
require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
|
|
280
|
+
options = target == "test_runner" ? { test_cmd: proposed_cmd } : { format_cmd: proposed_cmd }
|
|
281
|
+
adapter = Rubik::Adapter::GenericAdapter.new(lang, **options, guard: @guard)
|
|
282
|
+
adapter.config = @config
|
|
283
|
+
promotion_ctx.on_dispose do
|
|
284
|
+
@adapter_registry.unregister(lang) if !released && @adapter_registry.get(lang).equal?(adapter)
|
|
285
|
+
end
|
|
286
|
+
@adapter_registry.register(lang, adapter)
|
|
287
|
+
end
|
|
258
288
|
end
|
|
289
|
+
service = proposed_cmd
|
|
290
|
+
when "command"
|
|
291
|
+
service = proposed_cmd
|
|
259
292
|
end
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
293
|
+
|
|
294
|
+
root_ctx.provide(service_key, service)
|
|
295
|
+
phase = "Persistence"
|
|
296
|
+
persist_res = @config.set(cfg_key, proposed_cmd, persist: :project) if cfg_key && @config
|
|
297
|
+
raise persist_res[:error].to_s if persist_res.is_a?(Hash) && !persist_res[:ok]
|
|
298
|
+
rescue StandardError => e
|
|
299
|
+
promotion_ctx.dispose
|
|
300
|
+
rollback_errors = promotion_ctx.last_dispose_errors
|
|
301
|
+
detail = rollback_errors.empty? ? "previous session state restored" : "rollback incomplete: #{rollback_errors}"
|
|
302
|
+
return ToolOutput.error("#{phase} failed for #{target} (#{lang}): #{e.message}; #{detail}")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Keep one lifetime inverse per repaired key, preserving the original
|
|
306
|
+
# baseline across repeated repairs without retaining every transaction.
|
|
307
|
+
scope_key = [root_ctx, service_key]
|
|
308
|
+
if @repair_scopes.key?(scope_key)
|
|
309
|
+
released = true
|
|
310
|
+
promotion_ctx.dispose
|
|
311
|
+
else
|
|
312
|
+
@repair_scopes[scope_key] = promotion_ctx
|
|
263
313
|
end
|
|
264
314
|
|
|
265
315
|
if @config.nil? && ["lsp", "test_runner", "linter"].include?(target)
|
|
@@ -267,71 +317,14 @@ module Rubik
|
|
|
267
317
|
"so the command is active for this session only and was not saved."
|
|
268
318
|
end
|
|
269
319
|
|
|
270
|
-
if persist_res.is_a?(Hash) && !persist_res[:ok]
|
|
271
|
-
return "⚠️ Promoted '#{proposed_cmd}' for #{target} (#{lang}) — active in this session, " \
|
|
272
|
-
"but saving to the project config failed: #{persist_res[:error]}"
|
|
273
|
-
end
|
|
274
|
-
|
|
275
320
|
"✨ Successfully verified and promoted runtime service for #{target} (#{lang}): '#{proposed_cmd}'. " \
|
|
276
|
-
"Service is now active
|
|
321
|
+
"Service is now active in the live context."
|
|
277
322
|
end
|
|
278
323
|
|
|
279
324
|
private
|
|
280
325
|
|
|
281
|
-
def verify_binary(target, lang, proposed_cmd, sub_ctx)
|
|
282
|
-
|
|
283
|
-
when "lsp"
|
|
284
|
-
client = Rubik::LSP::Client.new(lang, root_dir: @root_dir, server_cmd: proposed_cmd)
|
|
285
|
-
if client.available?
|
|
286
|
-
sub_ctx.provide("lsp_probe:#{lang}", client)
|
|
287
|
-
[true, nil]
|
|
288
|
-
else
|
|
289
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
290
|
-
cmd: proposed_cmd,
|
|
291
|
-
exit_code: 127,
|
|
292
|
-
stderr: "Pre-flight check failed: LSP binary '#{client.binary_name}' not available on system"
|
|
293
|
-
)]
|
|
294
|
-
end
|
|
295
|
-
when "test_runner"
|
|
296
|
-
if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
|
|
297
|
-
sub_ctx.provide("runner_probe:#{lang}", proposed_cmd)
|
|
298
|
-
[true, nil]
|
|
299
|
-
else
|
|
300
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
301
|
-
cmd: proposed_cmd,
|
|
302
|
-
exit_code: 127,
|
|
303
|
-
stderr: "Pre-flight check failed: Test runner executable not found in PATH"
|
|
304
|
-
)]
|
|
305
|
-
end
|
|
306
|
-
when "linter"
|
|
307
|
-
if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
|
|
308
|
-
sub_ctx.provide("linter_probe:#{lang}", proposed_cmd)
|
|
309
|
-
[true, nil]
|
|
310
|
-
else
|
|
311
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
312
|
-
cmd: proposed_cmd,
|
|
313
|
-
exit_code: 127,
|
|
314
|
-
stderr: "Pre-flight check failed: Linter/formatter executable not found in PATH"
|
|
315
|
-
)]
|
|
316
|
-
end
|
|
317
|
-
when "command"
|
|
318
|
-
if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
|
|
319
|
-
sub_ctx.provide("command_probe:#{lang}", proposed_cmd)
|
|
320
|
-
[true, nil]
|
|
321
|
-
else
|
|
322
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
323
|
-
cmd: proposed_cmd,
|
|
324
|
-
exit_code: 127,
|
|
325
|
-
stderr: "Pre-flight check failed: Command executable not found in PATH"
|
|
326
|
-
)]
|
|
327
|
-
end
|
|
328
|
-
else
|
|
329
|
-
[false, Rubik::Runtime::DiagnosticEngine.diagnose(
|
|
330
|
-
cmd: proposed_cmd,
|
|
331
|
-
exit_code: 1,
|
|
332
|
-
stderr: "Unknown target component type: #{target}"
|
|
333
|
-
)]
|
|
334
|
-
end
|
|
326
|
+
def verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: nil)
|
|
327
|
+
Rubik::Runtime::RepairProbe.new(root_dir: @root_dir).verify(target, lang, proposed_cmd, sub_ctx, probe_args: probe_args)
|
|
335
328
|
end
|
|
336
329
|
end
|
|
337
330
|
|