rubik 0.1.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +5 -1
  3. data/CHANGELOG.md +169 -0
  4. data/README.md +137 -255
  5. data/bin/rubik +9 -0
  6. data/lib/rubik/ast/extraction_result.rb +37 -0
  7. data/lib/rubik/ast/extractor.rb +35 -9
  8. data/lib/rubik/ast/graph.rb +27 -1
  9. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  10. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  11. data/lib/rubik/buffer/apply_contract.rb +78 -0
  12. data/lib/rubik/buffer/manager.rb +61 -5
  13. data/lib/rubik/cli/workspace_trust.rb +78 -0
  14. data/lib/rubik/concurrency/event_loop.rb +56 -44
  15. data/lib/rubik/concurrency/scheduler.rb +7 -24
  16. data/lib/rubik/context/git_lister.rb +6 -1
  17. data/lib/rubik/context/scanner.rb +15 -3
  18. data/lib/rubik/kernel/init.rb +15 -0
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/auto_apply.rb +1 -1
  21. data/lib/rubik/orchestrator/subagent/activation.rb +29 -4
  22. data/lib/rubik/orchestrator/subagent/builtin_types.rb +117 -0
  23. data/lib/rubik/orchestrator/subagent/dynamic_store.rb +119 -0
  24. data/lib/rubik/orchestrator/subagent/instance.rb +8 -36
  25. data/lib/rubik/orchestrator/subagent/journal.rb +77 -4
  26. data/lib/rubik/orchestrator/subagent/profile_contract.rb +187 -0
  27. data/lib/rubik/orchestrator/subagent/profile_snapshot.rb +72 -0
  28. data/lib/rubik/orchestrator/subagent/reuse.rb +2 -1
  29. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +2 -0
  30. data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +170 -0
  31. data/lib/rubik/orchestrator/subagent/start_gate.rb +98 -0
  32. data/lib/rubik/orchestrator/subagent/supervisor.rb +4 -67
  33. data/lib/rubik/orchestrator/subagent/type_definition.rb +84 -0
  34. data/lib/rubik/orchestrator/subagent/type_registry.rb +139 -159
  35. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +4 -2
  36. data/lib/rubik/platform/process.rb +152 -3
  37. data/lib/rubik/plugin/manager.rb +148 -71
  38. data/lib/rubik/plugin/package_inventory.rb +84 -0
  39. data/lib/rubik/plugin/requires_dag.rb +40 -0
  40. data/lib/rubik/plugin/storage.rb +100 -14
  41. data/lib/rubik/prompt/context_builder.rb +54 -20
  42. data/lib/rubik/prompt/doctrine.rb +158 -22
  43. data/lib/rubik/prompt/token_estimator.rb +5 -3
  44. data/lib/rubik/prompt/tool_teachings.rb +19 -12
  45. data/lib/rubik/repl/commands/buffer.rb +28 -18
  46. data/lib/rubik/runtime/repair_probe.rb +98 -0
  47. data/lib/rubik/sandbox/driver/bubblewrap.rb +13 -59
  48. data/lib/rubik/sandbox/driver/docker.rb +14 -60
  49. data/lib/rubik/sandbox/driver/podman.rb +13 -56
  50. data/lib/rubik/sandbox/driver/process.rb +12 -64
  51. data/lib/rubik/security/blocked_guidance.rb +19 -0
  52. data/lib/rubik/security/guard.rb +116 -7
  53. data/lib/rubik/storage/store.rb +52 -11
  54. data/lib/rubik/tools/builtins/define_subagent.rb +69 -0
  55. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  56. data/lib/rubik/tools/builtins/storage.rb +8 -5
  57. data/lib/rubik/tools/builtins/subagent.rb +20 -53
  58. data/lib/rubik/tools/builtins/synthesis.rb +6 -1
  59. data/lib/rubik/tools/builtins.rb +1 -0
  60. data/lib/rubik/tools/diagnostic_reporter.rb +2 -1
  61. data/lib/rubik/version.rb +1 -1
  62. metadata +15 -1
@@ -36,9 +36,17 @@ module Rubik
36
36
  @symbols = {}
37
37
  @qualified_symbols = {}
38
38
  @file_symbols = Hash.new { |h, k| h[k] = [] }
39
+ @extraction_status = {}
39
40
  @mutex = Mutex.new
40
41
  end
41
42
 
43
+ # Last extraction status recorded for a file (issue #147): :ok,
44
+ # :partial, :failed, or :unavailable. Lets callers tell "no symbols"
45
+ # apart from "extraction failed" without re-running the extractor.
46
+ def extraction_status(file_path)
47
+ @mutex.synchronize { @extraction_status[file_path] }
48
+ end
49
+
42
50
  def symbols
43
51
  @mutex.synchronize do
44
52
  SymbolMap.new(@symbols.dup, @qualified_symbols.dup)
@@ -54,6 +62,7 @@ module Rubik
54
62
  @symbols.clear
55
63
  @qualified_symbols.clear
56
64
  @file_symbols.clear
65
+ @extraction_status.clear
57
66
  end
58
67
  end
59
68
 
@@ -66,6 +75,7 @@ module Rubik
66
75
  def remove_file(file_path)
67
76
  @mutex.synchronize do
68
77
  _remove_file_unlocked(file_path)
78
+ @extraction_status.delete(file_path)
69
79
  end
70
80
  end
71
81
 
@@ -218,12 +228,28 @@ module Rubik
218
228
  end
219
229
 
220
230
  def _index_file_unlocked(file_path, content = nil)
231
+ result = Extractor.extract_result(file_path, content)
232
+ @extraction_status[file_path] = result.status
233
+
234
+ # A failed extraction must not silently DELETE the file's previously
235
+ # indexed symbols (issue #147): stale-but-known beats unknown, and
236
+ # the log line makes the downgrade visible. The best-effort symbols
237
+ # are only indexed when there is nothing to protect.
238
+ if result.status == :failed
239
+ existing = _get_file_symbols_unlocked(file_path)
240
+ unless existing.empty?
241
+ Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); keeping previously indexed symbols")
242
+ return existing
243
+ end
244
+ Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); no symbols indexed")
245
+ end
246
+
221
247
  _remove_file_unlocked(file_path)
222
248
 
223
- extracted = Extractor.extract(file_path, content)
224
249
  ext = File.extname(file_path).downcase
225
250
  lang = CacheStore::LANGUAGE_MAP[ext] || "code"
226
251
 
252
+ extracted = result.symbols
227
253
  extracted.each do |sym|
228
254
  @symbols[sym.name] = sym
229
255
  q_key = "#{lang}:#{file_path}##{sym.name}"
@@ -5,31 +5,71 @@ require_relative "base_walker"
5
5
  module Rubik
6
6
  module AST
7
7
  module TreeSitter
8
+ # Shared C/C++ walker (issue #147). Supported scope, pinned by
9
+ # test_ast_cpp_extraction.rb:
10
+ # • includes, typedefs, and struct/union/class specifiers (nested
11
+ # classes qualify as Outer::Inner);
12
+ # • free functions and in-class member definitions/declarations,
13
+ # including constructors, destructors, and operator overloads;
14
+ # • namespaces: symbol names carry the namespace path (app::run);
15
+ # • templates: a template_declaration is unwrapped and its inner
16
+ # class/function is walked (specialization nuances are out of scope).
17
+ # Qualification follows the project convention: namespace/enclosing
18
+ # segments join with "::", an in-class member joins its class with "#"
19
+ # (app::Widget#run — the same Class#method shape the TypeScript and
20
+ # Python walkers use). Out-of-line definitions keep their written
21
+ # spelling (`void Widget::run` stays "Widget::run").
22
+ # Out of scope (documented boundary): macro bodies, free-function
23
+ # prototypes outside a class, local classes inside function bodies, and
24
+ # declarators hidden behind attributes/`decltype` syntax.
8
25
  class CWalker < BaseWalker
26
+ RECORD_KINDS = {
27
+ "class_specifier" => :class,
28
+ "struct_specifier" => :struct,
29
+ "union_specifier" => :union
30
+ }.freeze
31
+
32
+ DECLARATOR_WRAPPERS = [
33
+ "function_declarator", "pointer_declarator", "reference_declarator", "parenthesized_declarator"
34
+ ].freeze
35
+ NAME_NODE_KINDS = [
36
+ "identifier", "field_identifier", "type_identifier", "destructor_name", "operator_name"
37
+ ].freeze
38
+
9
39
  def walk(root_node)
10
40
  symbols = []
11
41
  return symbols unless root_node
12
42
 
13
- walk_children(root_node, symbols)
43
+ walk_children(root_node, symbols, [], member: false)
14
44
  symbols
15
45
  end
16
46
 
17
47
  private
18
48
 
19
- def walk_children(node, symbols)
49
+ # `scope` is the enclosing declaration path (namespaces, then the
50
+ # innermost class, outermost first); `member` marks a class body so
51
+ # declarations are read as member declarations.
52
+ def walk_children(node, symbols, scope, member:)
20
53
  (0...node.named_child_count).each do |i|
21
54
  child = node.named_child(i)
22
55
  case child.kind
23
56
  when "preproc_include"
24
57
  walk_include(child, symbols)
25
58
  when "type_definition"
26
- walk_typedef(child, symbols)
27
- when "struct_specifier"
28
- walk_struct(child, symbols)
59
+ walk_typedef(child, symbols, scope)
60
+ when *RECORD_KINDS.keys
61
+ walk_record(child, symbols, scope)
62
+ when "namespace_definition"
63
+ walk_namespace(child, symbols, scope)
29
64
  when "function_definition"
30
- walk_function(child, symbols)
65
+ walk_function(child, symbols, scope, member: member)
66
+ when "field_declaration", "declaration"
67
+ walk_member_declaration(child, symbols, scope) if member
31
68
  else
32
- walk_children(child, symbols)
69
+ # Plain recursion also unwraps template_declaration nodes (their
70
+ # inner class/function is walked in place) and linkage
71
+ # specifications.
72
+ walk_children(child, symbols, scope, member: member)
33
73
  end
34
74
  end
35
75
  end
@@ -44,7 +84,7 @@ module Rubik
44
84
  )
45
85
  end
46
86
 
47
- def walk_typedef(node, symbols)
87
+ def walk_typedef(node, symbols, scope)
48
88
  declarator = node.child_by_field_name("declarator")
49
89
  name = if declarator
50
90
  text_for(declarator).strip
@@ -54,32 +94,47 @@ module Rubik
54
94
  end
55
95
  return if name.empty?
56
96
 
97
+ qualified = qualify(scope, name)
57
98
  sig = clean_signature(node)
58
99
  symbols << build_symbol(
59
- name: name,
100
+ name: qualified,
60
101
  kind: :struct,
61
102
  node: node,
62
- signature: sig.empty? ? "typedef struct #{name}" : sig
103
+ signature: sig.empty? ? "typedef struct #{qualified}" : sig
63
104
  )
64
105
  end
65
106
 
66
- def walk_struct(node, symbols)
107
+ def walk_record(node, symbols, scope)
67
108
  name_node = node.child_by_field_name("name")
68
109
  return unless name_node
69
110
 
70
111
  name = text_for(name_node).strip
71
112
  return if name.empty?
72
113
 
73
- sig = "struct #{name}"
114
+ kind = RECORD_KINDS.fetch(node.kind, :struct)
115
+ qualified = qualify(scope, name)
116
+ sig = clean_signature(node)
74
117
  symbols << build_symbol(
75
- name: name,
76
- kind: :struct,
118
+ name: qualified,
119
+ kind: kind,
77
120
  node: node,
78
- signature: sig
121
+ signature: sig.empty? ? "#{kind} #{qualified}" : sig
79
122
  )
123
+
124
+ body = node.child_by_field_name("body")
125
+ walk_children(body, symbols, scope + [name], member: true) if body
126
+ end
127
+
128
+ # Namespace names extend the qualification path; everything inside is
129
+ # walked with the namespace prefix (app::run, app::Widget#run).
130
+ def walk_namespace(node, symbols, scope)
131
+ name_node = node.child_by_field_name("name")
132
+ ns_scope = name_node ? scope + [text_for(name_node).strip] : scope
133
+ body = node.child_by_field_name("body")
134
+ walk_children(body, symbols, ns_scope, member: false) if body
80
135
  end
81
136
 
82
- def walk_function(node, symbols)
137
+ def walk_function(node, symbols, scope, member:)
83
138
  decl = node.child_by_field_name("declarator")
84
139
  return unless decl
85
140
 
@@ -88,10 +143,11 @@ module Rubik
88
143
 
89
144
  body = node.child_by_field_name("body")
90
145
  calls = extract_calls(body, ["call_expression"])
146
+ qualified = member ? member_name(scope, fn_name) : qualify(scope, fn_name)
91
147
 
92
148
  sig = clean_signature(node)
93
149
  symbols << build_symbol(
94
- name: fn_name,
150
+ name: qualified,
95
151
  kind: :function,
96
152
  node: node,
97
153
  signature: sig.empty? ? "#{fn_name}()" : sig,
@@ -99,22 +155,74 @@ module Rubik
99
155
  )
100
156
  end
101
157
 
158
+ # In-class member DECLARATIONS without a body: `void run();`, `C();`,
159
+ # `~C();`, `void operator+(int);`, `operator bool() const;`. Plain
160
+ # field declarations (`int x;`) are not symbols.
161
+ def walk_member_declaration(node, symbols, scope)
162
+ return if scope.empty?
163
+
164
+ decl = node.child_by_field_name("declarator")
165
+ return unless decl
166
+
167
+ name = if decl.kind == "operator_cast"
168
+ type_node = decl.child_by_field_name("type")
169
+ type_node ? "operator #{text_for(type_node).strip}" : nil
170
+ elsif callable_declarator?(decl)
171
+ extract_function_name(decl)
172
+ end
173
+ return if name.nil? || name.empty?
174
+
175
+ sig = clean_signature(node)
176
+ symbols << build_symbol(
177
+ name: member_name(scope, name),
178
+ kind: :function,
179
+ node: node,
180
+ signature: sig.empty? ? "#{name}()" : sig
181
+ )
182
+ end
183
+
184
+ def callable_declarator?(decl)
185
+ current = decl
186
+ while current
187
+ return true if current.kind == "function_declarator"
188
+
189
+ break unless DECLARATOR_WRAPPERS.include?(current.kind)
190
+
191
+ current = current.child_by_field_name("declarator")
192
+ end
193
+ false
194
+ end
195
+
196
+ # Resolves a callable's name from its declarator chain: identifiers
197
+ # (C and free functions), field_identifier (in-class methods),
198
+ # destructor_name (~C), operator_name (operator+), and
199
+ # qualified_identifier (out-of-line Widget::run). Only known
200
+ # declarator wrappers are descended; anything else ends the walk with
201
+ # nil — a wrong name is worse than a missing symbol.
102
202
  def extract_function_name(declarator_node)
103
203
  current = declarator_node
104
204
  while current
105
- case current.kind
106
- when "function_declarator", "pointer_declarator"
107
- current = current.child_by_field_name("declarator")
108
- when "identifier"
109
- return text_for(current).strip
110
- else
111
- # Try first identifier child
112
- ident = current.children.find { |c| c.kind == "identifier" }
113
- return text_for(ident).strip if ident
205
+ kind = current.kind
206
+ return text_for(current).strip if NAME_NODE_KINDS.include?(kind) || kind == "qualified_identifier"
114
207
 
115
- return text_for(current).strip
116
- end
208
+ return nil unless DECLARATOR_WRAPPERS.include?(kind)
209
+
210
+ current = current.child_by_field_name("declarator")
117
211
  end
212
+ nil
213
+ end
214
+
215
+ # Namespace/enclosing-class segments join with "::"; an in-class
216
+ # member joins its class with "#" (see the class comment).
217
+ def qualify(scope, name)
218
+ scope.empty? ? name : "#{scope.join('::')}::#{name}"
219
+ end
220
+
221
+ def member_name(scope, name)
222
+ return name if scope.empty?
223
+
224
+ *outer, klass = scope
225
+ (outer + ["#{klass}##{name}"]).join("::")
118
226
  end
119
227
  end
120
228
  end
@@ -7,6 +7,7 @@ rescue LoadError
7
7
  TREE_SITTER_AVAILABLE = false
8
8
  end
9
9
 
10
+ require_relative "extraction_result"
10
11
  require_relative "treesitter/base_walker"
11
12
  require_relative "treesitter/python_walker"
12
13
  require_relative "treesitter/go_walker"
@@ -55,37 +56,64 @@ module Rubik
55
56
  Thread.current[:rubik_ts_parsers]&.clear
56
57
  end
57
58
 
59
+ # Array-or-nil seam (unchanged contract for every existing caller):
60
+ # nil means "fall back to the regex extractor".
58
61
  def extract(file_path, content = nil)
59
- return nil unless available?
62
+ result = extract_result(file_path, content)
63
+ result.trustworthy? ? result.symbols : nil
64
+ end
65
+
66
+ # Structured outcome (issue #147): status :ok / :partial / :failed /
67
+ # :unavailable plus best-effort symbols, so an empty result is never
68
+ # confused with a broken parse. See AST::ExtractionResult.
69
+ def extract_result(file_path, content = nil)
70
+ return unavailable_result unless available?
60
71
 
61
72
  ext = File.extname(file_path).downcase
62
73
  mapping = LANGUAGE_MAP[ext]
63
- return nil unless mapping
74
+ return unavailable_result unless mapping
64
75
 
65
76
  code = content || (File.exist?(file_path) ? File.read(file_path) : "")
66
- return [] if code.empty?
77
+ return ExtractionResult.new(symbols: [], status: :ok, source: :tree_sitter) if code.empty?
67
78
 
68
79
  code = code.encode("UTF-8", invalid: :replace, undef: :replace) unless code.valid_encoding?
69
80
 
70
81
  parser = parser_for(mapping[:parser])
71
- return nil unless parser
82
+ return failed_result("no parser for #{mapping[:parser]}") unless parser
72
83
 
73
84
  tree = parser.parse(code)
74
- return nil unless tree&.root_node
75
-
76
- walker = mapping[:walker].new(file_path, code)
77
- symbols = walker.walk(tree.root_node)
78
- has_err = if tree.root_node.respond_to?(:has_error?)
79
- tree.root_node.has_error?
80
- elsif tree.root_node.respond_to?(:has_error)
81
- tree.root_node.has_error
82
- end
83
- return nil if has_err && (symbols.nil? || symbols.empty?)
84
-
85
- symbols
85
+ return failed_result("parser returned no tree") unless tree&.root_node
86
+
87
+ symbols = mapping[:walker].new(file_path, code).walk(tree.root_node) || []
88
+ if root_has_error?(tree.root_node)
89
+ return failed_result("tree-sitter reported syntax errors with no symbols") if symbols.empty?
90
+
91
+ return ExtractionResult.new(symbols: symbols, status: :partial, source: :tree_sitter,
92
+ detail: "tree-sitter reported syntax errors")
93
+ end
94
+
95
+ ExtractionResult.new(symbols: symbols, status: :ok, source: :tree_sitter)
86
96
  rescue StandardError, SystemStackError, ScriptError => e
87
97
  warn "[WARN] TreeSitter extraction failed for #{file_path}: #{e.message}" if ENV["RUBIK_DEBUG"]
88
- nil
98
+ failed_result("#{e.class}: #{e.message}")
99
+ end
100
+
101
+ private
102
+
103
+ def root_has_error?(node)
104
+ if node.respond_to?(:has_error?)
105
+ node.has_error?
106
+ elsif node.respond_to?(:has_error)
107
+ node.has_error
108
+ end
109
+ end
110
+
111
+ def failed_result(detail)
112
+ ExtractionResult.new(symbols: [], status: :failed, source: :none, detail: detail)
113
+ end
114
+
115
+ def unavailable_result
116
+ ExtractionResult.new(symbols: [], status: :unavailable, source: :none)
89
117
  end
90
118
  end
91
119
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Buffer
5
+ # The one home for the apply CONTRACT vocabulary (issue #146):
6
+ # - who may apply the buffer: the model's apply_buffer tool is gated by
7
+ # buffer.model_can_apply (the operator !apply flow never is);
8
+ # - what a mid-run failure reports: the buffer is per-file atomic, NOT
9
+ # a multi-file transaction, so the report names what DID reach disk,
10
+ # what failed, what stays dirty for a retry, and skipped conflicts.
11
+ # Both planes (ApplyBufferTool, the !apply REPL command) render through
12
+ # here so the truth cannot drift between model and operator output.
13
+ module ApplyContract
14
+ module_function
15
+
16
+ # Authority resolution: the shell's live config wins when present; a
17
+ # tool rebuilt without a shell (ScopedRegistry branch workspaces) falls
18
+ # back to the buffer manager's own config. Missing config keeps the
19
+ # documented default (true).
20
+ def model_can_apply?(shell_config:, manager_config:)
21
+ cfg = shell_config || manager_config
22
+ return true if cfg.nil? || !cfg.respond_to?(:fetch)
23
+
24
+ cfg.fetch("buffer.model_can_apply", true) != false
25
+ end
26
+
27
+ # Partial-apply report lines. `sync` (when given) receives the applied
28
+ # paths and performs the context/AST sync; a raising sync is annotated,
29
+ # never allowed to swallow the report.
30
+ def failure_lines(failure, sync: nil)
31
+ lines = ["Apply stopped before completing; the buffer is not a multi-file transaction:"]
32
+ lines << if failure.applied.empty?
33
+ " • Nothing was written to disk."
34
+ else
35
+ " • Already written to disk (do not re-apply): #{failure.applied.join(', ')}"
36
+ end
37
+ cause = failure.cause
38
+ lines << " • Failed: #{failure.failed}#{cause ? " (#{cause.class}: #{cause.message})" : " (#{failure.kind})"}"
39
+ lines << " • Still dirty — a retry writes only these: #{failure.pending.join(', ')}" unless failure.pending.empty?
40
+ lines << " • Skipped conflicts (disk changed externally): #{failure.conflicts.join(', ')}" unless failure.conflicts.empty?
41
+ if sync
42
+ annotation = sync_annotation(failure, sync)
43
+ lines << annotation if annotation
44
+ end
45
+ lines
46
+ end
47
+
48
+ # "Buffer is clean" alone reads as "nothing happened" when an earlier
49
+ # apply (the tool or the auto-apply policy) already flushed the edits
50
+ # to disk. Surface the last successful apply so the model can verify
51
+ # against reality instead of re-editing or force-applying (issue #97
52
+ # finding 6). When the mode cannot be read, the origin stays
53
+ # deliberately two-sided rather than inventing a specific cause.
54
+ def clean_buffer_message(last_applied, mode)
55
+ paths = last_applied.is_a?(Hash) ? Array(last_applied[:paths]) : []
56
+ return "No unapplied buffer changes to write (buffer is clean)." if paths.empty?
57
+
58
+ origin = if !mode.nil? && !mode.to_s.strip.empty? && mode.to_s.downcase != "manual"
59
+ "the auto-apply policy (buffer.auto_apply: #{mode})"
60
+ else
61
+ "an earlier apply_buffer call or the auto-apply policy"
62
+ end
63
+ "No unapplied buffer changes to write (buffer is clean): #{paths.size} file(s) were already written to disk " \
64
+ "at #{last_applied[:at].utc.iso8601} by #{origin}: #{paths.join(', ')}."
65
+ end
66
+
67
+ def sync_annotation(failure, sync)
68
+ return nil if failure.applied.empty?
69
+
70
+ sync.call(failure.applied)
71
+ nil
72
+ rescue StandardError => e
73
+ " • context/AST sync failed for the already-written files: #{e.class}: #{e.message}"
74
+ end
75
+ private_class_method :sync_annotation
76
+ end
77
+ end
78
+ end
@@ -13,6 +13,25 @@ module Rubik
13
13
  module Buffer
14
14
  class InvalidPathError < StandardError; end
15
15
 
16
+ # Mid-run apply failure with the partial-progress report (issue #146):
17
+ # the buffer is NOT a multi-file transaction — entries already written
18
+ # stay on disk, and this error says exactly which. `kind` names the
19
+ # failure class (:write_failed or :containment_blocked); `cause` carries
20
+ # the underlying error. `pending` lists this run's entries that are
21
+ # still dirty (a retry writes only those).
22
+ class ApplyFailedError < StandardError
23
+ attr_reader :kind, :applied, :failed, :pending, :conflicts
24
+
25
+ def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [])
26
+ super(message)
27
+ @kind = kind
28
+ @applied = applied
29
+ @failed = failed
30
+ @pending = pending
31
+ @conflicts = conflicts
32
+ end
33
+ end
34
+
16
35
  class Manager
17
36
  attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs, :guard, :last_conflicts
18
37
  attr_accessor :allow_external_writes, :config
@@ -270,6 +289,12 @@ module Rubik
270
289
  diffs.join("\n")
271
290
  end
272
291
 
292
+ # Applies dirty buffer entries to disk (per-file atomic writes — NOT a
293
+ # multi-file transaction). On a mid-run failure the partial progress is
294
+ # carried by ApplyFailedError: applied entries ARE on disk, the failed
295
+ # entry stays dirty, and pending entries keep their buffer content, so
296
+ # a retry writes only the remainder. External-modification conflicts
297
+ # are skipped (recorded in last_conflicts) unless policy is "overwrite".
273
298
  def apply!(target_paths = nil, policy: nil)
274
299
  @monitor.synchronize do
275
300
  applied_paths = []
@@ -282,7 +307,7 @@ module Rubik
282
307
 
283
308
  effective_policy = (policy || external_modification_policy).to_s.downcase
284
309
 
285
- keys_to_apply.each do |rel_path|
310
+ keys_to_apply.each_with_index do |rel_path, index|
286
311
  next unless @dirty_files.key?(rel_path)
287
312
 
288
313
  unless PathValidator.valid?(rel_path)
@@ -323,14 +348,28 @@ module Rubik
323
348
  # last_conflicts on every run" holds for readers (AutoApply's
324
349
  # report_conflicts) even on the failure path.
325
350
  @last_conflicts = conflicts
326
- raise InvalidPathError,
327
- "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
328
- "(possible symlink swap since buffering). Re-verify the workspace and retry."
351
+ raise apply_failure(
352
+ "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
353
+ "(possible symlink swap since buffering). Re-verify the workspace and retry.",
354
+ kind: :containment_blocked, applied: applied_paths, failed: rel_path,
355
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts
356
+ )
329
357
  end
330
358
 
331
359
  content = @buffer[rel_path]
332
360
 
333
- Rubik::Platform.atomic_write(full_path, content)
361
+ begin
362
+ Rubik::Platform.atomic_write(full_path, content)
363
+ rescue StandardError => e
364
+ @last_conflicts = conflicts
365
+ raise apply_failure(
366
+ "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
367
+ "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
368
+ "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
369
+ kind: :write_failed, applied: applied_paths, failed: rel_path,
370
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts
371
+ ), cause: e
372
+ end
334
373
 
335
374
  @original_hashes[rel_path] = Digest::SHA256.hexdigest(content)
336
375
  @dirty_files.delete(rel_path)
@@ -422,6 +461,23 @@ module Rubik
422
461
  rescue Errno::ENOENT, Errno::EACCES
423
462
  nil
424
463
  end
464
+
465
+ # Build the structured partial-apply error with frozen report copies —
466
+ # a reader must see the state at raise time, not later mutations.
467
+ def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:)
468
+ ApplyFailedError.new(
469
+ message, kind: kind,
470
+ applied: applied.dup.freeze, failed: failed,
471
+ pending: pending.dup.freeze, conflicts: conflicts.dup.freeze
472
+ )
473
+ end
474
+
475
+ # This run's remaining dirty entries after the failed index — exactly
476
+ # what a retry will still have to write (garbage-skipped entries are
477
+ # already out of dirty_files when this runs).
478
+ def pending_after(keys, index)
479
+ keys[(index + 1)..].to_a.select { |k| @dirty_files.key?(k) }
480
+ end
425
481
  end
426
482
  end
427
483
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tty-prompt"
5
+ require "yaml/store"
6
+
7
+ module Rubik
8
+ module CLI
9
+ class WorkspaceTrust
10
+ TRUSTED_WORKSPACES_KEY = "trusted_workspaces"
11
+ TRUST_OPTION = "Trust and continue"
12
+ EXIT_OPTION = "Exit"
13
+
14
+ class << self
15
+ def authorize!(workspace:, input: $stdin, output: $stdout, store_path: default_store_path)
16
+ return true unless UI::Capability.interactive?(input: input, output: output)
17
+
18
+ workspace_path = File.realpath(workspace)
19
+ return true if trusted?(workspace_path, store_path: store_path)
20
+
21
+ output.puts <<~NOTICE
22
+ Workspace trust is required before Rubik loads this workspace:
23
+ #{workspace_path}
24
+
25
+ Rubik can read and modify workspace files and run foreground and background commands.
26
+ Some commands may run without a separate approval. OS sandboxing is disabled by default.
27
+
28
+ `buffer.model_can_apply: false` only restricts applying staged buffer changes; it does
29
+ not restrict shell commands.
30
+ NOTICE
31
+
32
+ choice = TTY::Prompt.new(input: input, output: output).select(
33
+ "Choose an option:",
34
+ [TRUST_OPTION, EXIT_OPTION],
35
+ default: EXIT_OPTION,
36
+ filter: false,
37
+ per_page: 2
38
+ )
39
+ return false unless choice == TRUST_OPTION
40
+
41
+ trust!(workspace_path, store_path: store_path)
42
+ true
43
+ rescue StandardError => e
44
+ output.puts "[Rubik] Workspace trust could not be established: #{e.message}. Exiting."
45
+ false
46
+ end
47
+
48
+ def trusted?(workspace, store_path: default_store_path)
49
+ return false unless File.file?(store_path)
50
+
51
+ store = YAML::Store.new(store_path, true)
52
+ store.transaction(true) do
53
+ Array(store[TRUSTED_WORKSPACES_KEY]).include?(File.realpath(workspace))
54
+ end
55
+ rescue Psych::Exception, SystemCallError
56
+ false
57
+ end
58
+
59
+ def trust!(workspace, store_path: default_store_path)
60
+ FileUtils.mkdir_p(File.dirname(store_path), mode: 0o700)
61
+ workspace_path = File.realpath(workspace)
62
+ store = YAML::Store.new(store_path, true)
63
+ store.transaction do
64
+ paths = Array(store[TRUSTED_WORKSPACES_KEY])
65
+ store[TRUSTED_WORKSPACES_KEY] = (paths + [workspace_path]).uniq
66
+ end
67
+ File.chmod(0o600, store_path)
68
+ end
69
+
70
+ private
71
+
72
+ def default_store_path
73
+ File.join(Platform.home_dir, ".rubik", "trusted_workspaces.yml")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end