rubino-agent 0.3.0 → 0.5.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +172 -5
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +35 -3
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/skills.md +31 -0
  15. data/docs/troubleshooting.md +1 -1
  16. data/exe/rubino +16 -2
  17. data/install.sh +721 -59
  18. data/lib/rubino/active_agent.rb +73 -0
  19. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  20. data/lib/rubino/agent/agent_registry.rb +5 -2
  21. data/lib/rubino/agent/definition.rb +1 -9
  22. data/lib/rubino/agent/fallback_chain.rb +0 -6
  23. data/lib/rubino/agent/iteration_budget.rb +109 -3
  24. data/lib/rubino/agent/loop.rb +476 -20
  25. data/lib/rubino/agent/model_call_runner.rb +81 -3
  26. data/lib/rubino/agent/prompts/build.txt +22 -5
  27. data/lib/rubino/agent/response_validator.rb +8 -0
  28. data/lib/rubino/agent/runner.rb +133 -8
  29. data/lib/rubino/agent/tool_executor.rb +166 -14
  30. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  31. data/lib/rubino/api/server.rb +19 -0
  32. data/lib/rubino/attachments/classify.rb +35 -17
  33. data/lib/rubino/boot/config_guard.rb +71 -0
  34. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  35. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  36. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  37. data/lib/rubino/cli/chat_command.rb +1189 -50
  38. data/lib/rubino/cli/commands.rb +282 -2
  39. data/lib/rubino/cli/config_command.rb +68 -8
  40. data/lib/rubino/cli/doctor_command.rb +204 -12
  41. data/lib/rubino/cli/jobs_command.rb +12 -0
  42. data/lib/rubino/cli/memory_command.rb +53 -20
  43. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  44. data/lib/rubino/cli/session_command.rb +172 -18
  45. data/lib/rubino/cli/setup_command.rb +131 -8
  46. data/lib/rubino/cli/skills_command.rb +183 -9
  47. data/lib/rubino/cli/trust_gate.rb +16 -7
  48. data/lib/rubino/commands/built_ins.rb +2 -0
  49. data/lib/rubino/commands/command.rb +12 -2
  50. data/lib/rubino/commands/executor.rb +149 -12
  51. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  52. data/lib/rubino/commands/handlers/agents.rb +156 -41
  53. data/lib/rubino/commands/handlers/config.rb +4 -1
  54. data/lib/rubino/commands/handlers/help.rb +113 -14
  55. data/lib/rubino/commands/handlers/memory.rb +15 -5
  56. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  57. data/lib/rubino/commands/handlers/status.rb +9 -4
  58. data/lib/rubino/commands/loader.rb +12 -0
  59. data/lib/rubino/config/configuration.rb +86 -24
  60. data/lib/rubino/config/defaults.rb +140 -33
  61. data/lib/rubino/config/loader.rb +62 -12
  62. data/lib/rubino/config/validator.rb +341 -0
  63. data/lib/rubino/config/writer.rb +123 -31
  64. data/lib/rubino/context/compressor.rb +184 -22
  65. data/lib/rubino/context/environment_inspector.rb +2 -2
  66. data/lib/rubino/context/file_discovery.rb +2 -2
  67. data/lib/rubino/context/message_boundary.rb +27 -1
  68. data/lib/rubino/context/project_languages.rb +90 -0
  69. data/lib/rubino/context/prompt_assembler.rb +105 -22
  70. data/lib/rubino/context/summary_builder.rb +45 -4
  71. data/lib/rubino/context/token_budget.rb +36 -11
  72. data/lib/rubino/context/token_estimate.rb +45 -0
  73. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  74. data/lib/rubino/database/connection.rb +154 -3
  75. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  76. data/lib/rubino/database/migrator.rb +98 -5
  77. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  78. data/lib/rubino/documents/converters/csv.rb +4 -3
  79. data/lib/rubino/documents/converters/docx.rb +29 -5
  80. data/lib/rubino/documents/converters/html.rb +5 -1
  81. data/lib/rubino/documents/converters/json.rb +2 -1
  82. data/lib/rubino/documents/converters/pdf.rb +11 -2
  83. data/lib/rubino/documents/converters/plain.rb +2 -1
  84. data/lib/rubino/documents/converters/pptx.rb +11 -2
  85. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  86. data/lib/rubino/documents/converters/xml.rb +2 -1
  87. data/lib/rubino/documents/limits.rb +210 -0
  88. data/lib/rubino/documents.rb +10 -3
  89. data/lib/rubino/errors.rb +36 -5
  90. data/lib/rubino/interaction/cancel_token.rb +19 -3
  91. data/lib/rubino/interaction/events.rb +13 -0
  92. data/lib/rubino/interaction/lifecycle.rb +99 -13
  93. data/lib/rubino/interaction/polishing.rb +176 -0
  94. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  95. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  96. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  97. data/lib/rubino/jobs/queue.rb +63 -8
  98. data/lib/rubino/jobs/runner.rb +24 -6
  99. data/lib/rubino/jobs/worker.rb +0 -4
  100. data/lib/rubino/llm/adapter_response.rb +47 -4
  101. data/lib/rubino/llm/credential_check.rb +15 -16
  102. data/lib/rubino/llm/error_classifier.rb +89 -1
  103. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  104. data/lib/rubino/llm/request.rb +30 -3
  105. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  106. data/lib/rubino/llm/tool_bridge.rb +113 -9
  107. data/lib/rubino/mcp/manager.rb +18 -1
  108. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  109. data/lib/rubino/memory/aux_retry.rb +107 -0
  110. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  111. data/lib/rubino/memory/backends.rb +23 -7
  112. data/lib/rubino/memory/salience_gate.rb +103 -0
  113. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  114. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  115. data/lib/rubino/memory/store.rb +33 -5
  116. data/lib/rubino/memory/threat_scanner.rb +52 -0
  117. data/lib/rubino/output/cost.rb +52 -0
  118. data/lib/rubino/output/headless_block_latch.rb +53 -0
  119. data/lib/rubino/output/result_serializer.rb +222 -0
  120. data/lib/rubino/output/turn_recorder.rb +77 -0
  121. data/lib/rubino/security/approval_policy.rb +227 -32
  122. data/lib/rubino/security/command_allowlist.rb +79 -4
  123. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  124. data/lib/rubino/security/hardline_guard.rb +189 -16
  125. data/lib/rubino/security/pattern_matcher.rb +28 -5
  126. data/lib/rubino/security/prefix_deriver.rb +25 -6
  127. data/lib/rubino/security/readonly_commands.rb +145 -5
  128. data/lib/rubino/security/secret_path.rb +134 -0
  129. data/lib/rubino/security/url_safety.rb +255 -0
  130. data/lib/rubino/session/repository.rb +212 -11
  131. data/lib/rubino/session/store.rb +139 -14
  132. data/lib/rubino/skills/installer.rb +230 -0
  133. data/lib/rubino/skills/prompt_index.rb +2 -2
  134. data/lib/rubino/skills/registry.rb +52 -1
  135. data/lib/rubino/skills/skill.rb +64 -3
  136. data/lib/rubino/skills/skill_tool.rb +16 -5
  137. data/lib/rubino/tools/background_tasks.rb +157 -13
  138. data/lib/rubino/tools/base.rb +204 -3
  139. data/lib/rubino/tools/edit_tool.rb +73 -18
  140. data/lib/rubino/tools/glob_tool.rb +48 -9
  141. data/lib/rubino/tools/grep_tool.rb +103 -9
  142. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  143. data/lib/rubino/tools/patch_tool.rb +5 -0
  144. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  145. data/lib/rubino/tools/read_tool.rb +33 -15
  146. data/lib/rubino/tools/read_tracker.rb +153 -35
  147. data/lib/rubino/tools/registry.rb +113 -12
  148. data/lib/rubino/tools/result.rb +9 -1
  149. data/lib/rubino/tools/ruby_tool.rb +0 -0
  150. data/lib/rubino/tools/shell_registry.rb +70 -0
  151. data/lib/rubino/tools/shell_tool.rb +40 -1
  152. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  153. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  154. data/lib/rubino/tools/task_tool.rb +36 -8
  155. data/lib/rubino/tools/vision_tool.rb +5 -0
  156. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  157. data/lib/rubino/tools/websearch_tool.rb +92 -30
  158. data/lib/rubino/tools/write_tool.rb +23 -4
  159. data/lib/rubino/ui/api.rb +10 -1
  160. data/lib/rubino/ui/base.rb +11 -0
  161. data/lib/rubino/ui/bottom_composer.rb +382 -74
  162. data/lib/rubino/ui/cli.rb +515 -83
  163. data/lib/rubino/ui/completion_menu.rb +11 -7
  164. data/lib/rubino/ui/headless_trace.rb +63 -0
  165. data/lib/rubino/ui/live_region.rb +70 -7
  166. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  167. data/lib/rubino/ui/notifier.rb +0 -2
  168. data/lib/rubino/ui/null.rb +52 -5
  169. data/lib/rubino/ui/paste_store.rb +16 -2
  170. data/lib/rubino/ui/queued_indicators.rb +6 -1
  171. data/lib/rubino/ui/status_bar.rb +61 -7
  172. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  173. data/lib/rubino/ui/subagent_view.rb +29 -4
  174. data/lib/rubino/ui/tool_label.rb +52 -0
  175. data/lib/rubino/update_check.rb +39 -4
  176. data/lib/rubino/util/atomic_file.rb +117 -0
  177. data/lib/rubino/util/ignore_rules.rb +120 -0
  178. data/lib/rubino/util/output.rb +229 -12
  179. data/lib/rubino/util/secrets_mask.rb +70 -7
  180. data/lib/rubino/util/spill_store.rb +153 -0
  181. data/lib/rubino/version.rb +1 -1
  182. data/lib/rubino/workspace.rb +9 -1
  183. data/lib/rubino.rb +191 -7
  184. data/rubino-agent.gemspec +1 -0
  185. data/skills/ruby-expert/SKILL.md +1 -0
  186. metadata +42 -12
  187. data/lib/rubino/agent/router.rb +0 -65
  188. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  189. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  190. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  191. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  192. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  193. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  194. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  195. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  196. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
@@ -34,6 +34,21 @@ module Rubino
34
34
  # can emit it as plain text — never lost)
35
35
  class StreamingMarkdown
36
36
  FENCE_RE = /\A\s*```/
37
+ # An OPENING fence captures its run of backticks (≥3) and any info string
38
+ # (the language tag) that follows. The CommonMark rule we lean on: a fenced
39
+ # block is closed only by a bare fence — no info string — of AT LEAST as
40
+ # many backticks. That keeps a nested ```ruby inside an outer ```markdown
41
+ # from being mistaken for the close (it carries an info string), so the
42
+ # whole wrapped block stays one unit instead of mis-toggling (#264).
43
+ FENCE_OPEN_RE = /\A\s*(`{3,})\s*(\S.*)?\z/
44
+ FENCE_CLOSE_RE = /\A\s*(`{3,})\s*\z/
45
+ # Info strings that mean "this fence WRAPS markdown" — the model boxed a
46
+ # whole answer (which itself contains nested ```lang fences) in an outer
47
+ # ```markdown / ```md. For those, and ONLY those, we track fence-nesting
48
+ # DEPTH: a nested fence's bare close must not be mistaken for the wrapper's
49
+ # close (T1), so the whole wrapped answer stays ONE block. The renderer
50
+ # re-renders such a body AS markdown (MarkdownRenderer::MARKDOWN_FENCE_LANGS).
51
+ MARKDOWN_FENCE_LANGS = %w[markdown md].freeze
37
52
  # An ordered ("1. ", "2) ") or unordered ("- ", "* ", "+ ") list item.
38
53
  # Used so a loose list (blank lines BETWEEN items) is kept as ONE block
39
54
  # instead of being split per-item: each split item was re-rendered on its
@@ -45,6 +60,13 @@ module Rubino
45
60
  @pending = +"" # un-newlined remainder (the live tail-in-progress line)
46
61
  @block = [] # completed lines accumulated for the current block
47
62
  @in_fence = false
63
+ @fence_len = 0 # backtick count of the OPEN fence (close needs ≥ this many)
64
+ # When the OPEN fence is a ```markdown / ```md wrapper, we track nesting
65
+ # DEPTH (starts at 1 on open): nested opening fences ++ it, bare closes of
66
+ # ≥ @fence_len -- it, and the block completes only when it returns to 0.
67
+ # nil = the open fence is a PLAIN code fence (no nesting; closes on the
68
+ # first bare fence of ≥ @fence_len), the CommonMark default (T1).
69
+ @fence_depth = nil
48
70
  @in_list = false # current block is a markdown list (keep loose items together)
49
71
  @blanks = 0 # blank lines buffered inside a list, re-emitted iff it continues
50
72
  end
@@ -109,6 +131,8 @@ module Rubino
109
131
  text = @block.join("\n")
110
132
  @block = []
111
133
  @in_fence = false
134
+ @fence_len = 0
135
+ @fence_depth = nil
112
136
  @in_list = false
113
137
  @blanks = 0
114
138
  text
@@ -121,15 +145,22 @@ module Rubino
121
145
  def consume_line(line)
122
146
  if @in_fence
123
147
  @block << line
124
- if line.match?(FENCE_RE) # closing fence ends the code block
125
- @in_fence = false
126
- return take_block
127
- end
128
- return nil
148
+ return nil unless fence_line_closes?(line)
149
+
150
+ @in_fence = false
151
+ @fence_len = 0
152
+ @fence_depth = nil
153
+ return take_block
129
154
  end
130
155
 
131
- if line.match?(FENCE_RE) # opening fence starts a code block
156
+ if (m = line.match(FENCE_OPEN_RE)) # opening fence starts a code block
132
157
  @in_fence = true
158
+ @fence_len = m[1].length
159
+ # A ```markdown / ```md wrapper arms nesting-DEPTH tracking (starts at
160
+ # 1); its body's nested ```lang fences must not close it early (T1).
161
+ # Any other language (or none) is a plain code fence: depth stays nil,
162
+ # so it closes on the first bare fence of ≥ @fence_len (CommonMark).
163
+ @fence_depth = MARKDOWN_FENCE_LANGS.include?(m[2].to_s.strip.downcase) ? 1 : nil
133
164
  flush_blanks
134
165
  @block << line
135
166
  return nil
@@ -167,6 +198,28 @@ module Rubino
167
198
  nil
168
199
  end
169
200
 
201
+ # Does this line (already appended to the open fence's block) CLOSE that
202
+ # fence? For a PLAIN code fence (@fence_depth nil) the first bare fence of
203
+ # ≥ the opening run closes it — the CommonMark default. For a ```markdown /
204
+ # ```md WRAPPER we track nesting depth: a nested OPENING fence (carries an
205
+ # info string, e.g. ```ruby) deepens it, a bare close of ≥ @fence_len
206
+ # un-nests one level, and only the close that returns depth to 0 ends the
207
+ # block — so the wrapper's nested fences never close it early (T1).
208
+ def fence_line_closes?(line)
209
+ if @fence_depth.nil?
210
+ m = line.match(FENCE_CLOSE_RE)
211
+ return !!(m && m[1].length >= @fence_len)
212
+ end
213
+
214
+ if (m = line.match(FENCE_CLOSE_RE)) && m[1].length >= @fence_len
215
+ @fence_depth -= 1
216
+ return @fence_depth <= 0
217
+ end
218
+ # A nested opening fence (```lang, with an info string) deepens the wrapper.
219
+ @fence_depth += 1 if line.match?(FENCE_OPEN_RE)
220
+ false
221
+ end
222
+
170
223
  # Re-emit blank lines buffered inside a continuing list so loose-list
171
224
  # spacing is preserved in the committed block text.
172
225
  def flush_blanks
@@ -28,7 +28,8 @@ module Rubino
28
28
  # single in-place line per subagent (`▸ sa_… · explore · running · N tools ·
29
29
  # Ns · <last_activity>`) that updates without scrolling — see UI::CLI
30
30
  # #set_subagent_cards / UI::SubagentCards. The /agents <id> drill-in tails the
31
- # same registry ring for the live recent: list (#71).
31
+ # same registry ring for the live recent: list (#71) and the entry's
32
+ # output_tail — fed by #tool_chunk — for the live output: block (#5).
32
33
  #
33
34
  # The view is wired with the entry id at construction (TaskTool builds it per
34
35
  # background run). With no id (legacy/foreground synchronous path, tests) it
@@ -124,10 +125,20 @@ module Rubino
124
125
  end
125
126
  end
126
127
 
127
- # tool_body / tool_chunk: the child's tool previews/streamed chunks. Kept
128
- # quiet to stay low-noise the start/finish rows already say what ran.
128
+ # Card mode: append the streamed chunk to the entry's bounded output tail
129
+ # the live output: block the /agents <id> watch tails while THIS tool runs
130
+ # (#5). Registry-only, NO card repaint: a chatty shell streams a chunk per
131
+ # line and repainting the cards per chunk would flood the parent terminal
132
+ # (the watch drill-in re-reads the tail on its own tick). Legacy mode
133
+ # stays quiet (the start/finish rows already say what ran).
134
+ def tool_chunk(_name, chunk, kind: :plain)
135
+ Tools::BackgroundTasks.instance.record_tool_output(@entry_id, chunk) if card_mode?
136
+ end
137
+
138
+ # tool_body: the END-OF-CALL preview of a NON-streaming tool (the executor
139
+ # skips it when the tool streamed via #tool_chunk). Useless for the live
140
+ # tail — tool_finished wipes the buffer right after — so it stays quiet.
129
141
  def tool_body(_text, kind: :plain); end
130
- def tool_chunk(_name, _chunk); end
131
142
 
132
143
  # --- Suppressed: the child's prose / token stream ---------------------
133
144
 
@@ -185,6 +196,20 @@ module Rubino
185
196
  false
186
197
  end
187
198
 
199
+ # Whether this nested view can actually put an approval in front of a
200
+ # human and block for an answer. ONLY the card-mode background path with a
201
+ # wired @approve handler can (it parks the child on a per-entry gate and a
202
+ # /agents <id> decision resolves it). The foreground/legacy path (no
203
+ # @approve) cannot — there is no one to ask. Returning false there makes
204
+ # the child's ToolExecutor FAIL CLOSED with the honest "needs approval but
205
+ # no interactive session — use --yolo / allowlist it" block (#260) instead
206
+ # of routing through #confirm's auto-deny, which the model otherwise reads
207
+ # as "the user denied it" though no human ever decided (#419). With a
208
+ # handler we stay interactive so the gate path is reached as before.
209
+ def interactive?
210
+ !@approve.nil?
211
+ end
212
+
188
213
  # No interactive clarification mid-delegation either.
189
214
  def ask(_prompt)
190
215
  nil
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # Shared vocabulary for the concise `name hint` tool label used across
6
+ # adapters — the interactive `● edit foo.rb` card open-row (UI::CLI) AND the
7
+ # non-interactive one-shot stderr trace (UI::HeadlessTrace) draw their hint
8
+ # from here so the two never drift. The hint is the single most-identifying
9
+ # argument (pattern / file_path / path / command), secrets-masked and
10
+ # terminal-sanitized so an untrusted filename/command can never drive the
11
+ # terminal from a label row (R3C-1, CWE-150).
12
+ module ToolLabel
13
+ module_function
14
+
15
+ # Picks the most-identifying [key, value] pair from a tool's arguments,
16
+ # in priority order. Returns nil when none of the known keys carry a value.
17
+ def pick_hint(arguments)
18
+ return nil unless arguments.is_a?(Hash)
19
+
20
+ %i[pattern file_path path command].each do |k|
21
+ v = arguments[k] || arguments[k.to_s]
22
+ return [k, v] if v && !v.to_s.empty?
23
+ end
24
+ nil
25
+ end
26
+
27
+ # The concise, sanitized hint string for a tool's arguments — the first
28
+ # line, secrets-masked, escape-stripped, truncated to `max` chars. Returns
29
+ # nil when there's no identifying argument. `verbose:` raises the cap so a
30
+ # `--verbose` one-shot trace can show fuller args.
31
+ def hint(arguments, max: 60, verbose: false)
32
+ picked = pick_hint(arguments)
33
+ return nil unless picked
34
+
35
+ raw_key, raw_value = picked
36
+ masked = Util::SecretsMask.mask_value(raw_value, key: raw_key).to_s
37
+ clean = Util::Output.sanitize_terminal(masked)
38
+ first = clean.lines.first.to_s.strip
39
+ cap = verbose ? max * 4 : max
40
+ first.length > cap ? "#{first[0, cap - 3]}..." : first
41
+ end
42
+
43
+ # The full `name hint` label (e.g. `edit foo.rb`, `bash npm test`,
44
+ # `read README.md`). Falls back to the bare tool name when the tool has no
45
+ # identifying argument.
46
+ def label(name, arguments, max: 60, verbose: false)
47
+ h = hint(arguments, max: max, verbose: verbose)
48
+ h && !h.empty? ? "#{name} #{h}" : name.to_s
49
+ end
50
+ end
51
+ end
52
+ end
@@ -42,6 +42,12 @@ module Rubino
42
42
  # notice must not leak through either (#66).
43
43
  def notice_from_cache
44
44
  return nil if opted_out?
45
+ # Never render off a poisoned cache: a future-dated checked_at (the
46
+ # "v99.0.0" phantom came from {"checked_at":"2099-…","latest":"99.0.0"})
47
+ # is suppressed here, and stale? treats it as stale so a refresh
48
+ # repopulates it for next boot. A legitimately >24h-old cache is still
49
+ # trusted for the notice — only forward-dated / corrupt entries are poison.
50
+ return nil if poisoned_cache?
45
51
 
46
52
  latest = cached_latest
47
53
  return nil unless newer?(latest)
@@ -59,6 +65,7 @@ module Rubino
59
65
  return nil unless stale?
60
66
 
61
67
  Thread.new do
68
+ Thread.current[:rubino_update_refresh] = true
62
69
  latest = fetch_latest
63
70
  write_cache(latest) if latest
64
71
  rescue StandardError
@@ -138,14 +145,42 @@ module Rubino
138
145
  ENV["CI"].to_s.strip.empty?
139
146
  end
140
147
 
141
- # True when the cache is missing or its checked_at is older than 24h.
148
+ # True when the cache is missing, unreadable, its checked_at is older than
149
+ # 24h — AND, crucially, also true when checked_at is in the FUTURE.
150
+ #
151
+ # A future checked_at (clock skew, a hand-edited or corrupt cache, a bogus
152
+ # fixture) yields a NEGATIVE age, which `>= 24h` is never true for — so the
153
+ # old code latched the poisoned cache forever and re-rendered its bogus
154
+ # `latest` on every boot (the live "rubino v99.0.0 available" phantom, from
155
+ # a cache pinned at {"checked_at":"2099-…","latest":"99.0.0"}). Treating a
156
+ # negative age as stale forces a re-check, letting the notifier self-heal.
142
157
  def stale?
143
- return true unless File.exist?(cache_path)
158
+ age = cache_age
159
+ return true if age.nil?
160
+
161
+ age.negative? || age >= CHECK_INTERVAL
162
+ end
163
+
164
+ # A cache whose checked_at is in the future is poison: it cannot reflect a
165
+ # real check and must never drive the boot notice. Missing/unreadable caches
166
+ # are NOT "poisoned" (they simply yield no notice); only a forward-dated or
167
+ # unparseable timestamp is.
168
+ def poisoned_cache?
169
+ return false unless File.exist?(cache_path)
170
+
171
+ age = cache_age
172
+ age.nil? || age.negative?
173
+ end
174
+
175
+ # Seconds since the cached checked_at (negative if it is in the future), or
176
+ # nil when the cache is missing, unreadable, or has an unparseable timestamp.
177
+ def cache_age
178
+ return nil unless File.exist?(cache_path)
144
179
 
145
180
  checked_at = JSON.parse(File.read(cache_path))["checked_at"]
146
- Time.now.utc - Time.parse(checked_at) >= CHECK_INTERVAL
181
+ Time.now.utc - Time.parse(checked_at)
147
182
  rescue StandardError
148
- true
183
+ nil
149
184
  end
150
185
 
151
186
  # ---- update mechanics -------------------------------------------------
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rubino
6
+ module Util
7
+ # Crash- and concurrency-safe writes to a shared state file.
8
+ #
9
+ # Several files in rubino are read-modify-written by commands the user can
10
+ # legitimately run in parallel: the skills provenance ledger
11
+ # (`.sources.json`), the YAML config (`config.yml`). A plain
12
+ # read → mutate → `File.write` has two defects under concurrency:
13
+ #
14
+ # * lost update — two writers read the same base, each writes its own
15
+ # mutation, the second clobbers the first (e.g. 4 parallel
16
+ # `skills install` → only the last 2 ledger entries survive); and
17
+ # * torn file — a writer interrupted mid-`write` (or interleaved with a
18
+ # reader) leaves a half-written, unparseable file that bricks every
19
+ # later command (e.g. corrupt `config.yml`).
20
+ #
21
+ # `update` fixes both with the standard POSIX recipe:
22
+ #
23
+ # 1. `flock(LOCK_EX)` on a dedicated `<target>.lock` sibling — a separate
24
+ # file so the lock outlives any rename/replace of the data file and a
25
+ # reader's `LOCK_SH` never races the writer's rename of the data file
26
+ # itself. The whole read-modify-write runs under the lock, so writers
27
+ # serialize and none reads a base another is about to overwrite.
28
+ # 2. write the new contents to a temp file IN THE SAME DIRECTORY (so the
29
+ # final rename is same-filesystem, hence atomic), then `fsync` it.
30
+ # 3. `File.rename(tmp, target)` — atomic on POSIX: a concurrent reader sees
31
+ # either the whole old file or the whole new one, never a torn mix.
32
+ # 4. `fsync` the directory so the rename survives a crash.
33
+ #
34
+ # Readers that want a consistent snapshot can take `LOCK_SH` over the same
35
+ # lock via `.read_shared`; a plain `File.read` is also safe against tearing
36
+ # because the rename is atomic (it just may observe a slightly stale file).
37
+ module AtomicFile
38
+ module_function
39
+
40
+ # Serialized read-modify-write of +path+. Yields the current file contents
41
+ # (a String, or nil when the file doesn't exist yet) while holding an
42
+ # exclusive lock, and atomically writes back whatever the block returns.
43
+ # If the block returns nil the file is left untouched (no-op write).
44
+ # Returns the block's value.
45
+ def update(path)
46
+ FileUtils.mkdir_p(File.dirname(path))
47
+ with_lock(path, File::LOCK_EX) do
48
+ current = File.file?(path) ? File.read(path) : nil
49
+ new_contents = yield(current)
50
+ write_atomic(path, new_contents) unless new_contents.nil?
51
+ new_contents
52
+ end
53
+ end
54
+
55
+ # Reads +path+ under a shared lock (so it can't observe a concurrent
56
+ # writer's intermediate state). Returns the contents, or nil when absent.
57
+ def read_shared(path)
58
+ return nil unless File.file?(path)
59
+
60
+ with_lock(path, File::LOCK_SH) do
61
+ File.file?(path) ? File.read(path) : nil
62
+ end
63
+ end
64
+
65
+ # Write +contents+ to +path+ via temp-file + atomic rename, fsyncing the
66
+ # temp file and the directory. Standalone (no lock) for callers that
67
+ # already hold one, or that only need crash-safety, not serialization.
68
+ def write_atomic(path, contents)
69
+ dir = File.dirname(path)
70
+ FileUtils.mkdir_p(dir)
71
+ # Preserve plain-write semantics on an EXISTING read-only file: a temp +
72
+ # rename would otherwise sidestep the file's own 0444 (the writable dir
73
+ # lets us swap it in), silently clobbering a file the user marked
74
+ # read-only. Refuse with the same EACCES a File.write would have raised.
75
+ existing_mode = File.exist?(path) ? File.stat(path).mode : nil
76
+ raise Errno::EACCES, path if existing_mode && !File.writable?(path)
77
+
78
+ tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.#{rand(1 << 32)}.tmp")
79
+ begin
80
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
81
+ f.write(contents)
82
+ f.flush
83
+ f.fsync
84
+ end
85
+ # Carry the original file's permission bits across the replace so an
86
+ # in-place edit doesn't silently re-chmod the user's file to 0600.
87
+ File.chmod(existing_mode & 0o777, tmp) if existing_mode
88
+ File.rename(tmp, path)
89
+ fsync_dir(dir)
90
+ ensure
91
+ FileUtils.rm_f(tmp)
92
+ end
93
+ end
94
+
95
+ def with_lock(path, mode)
96
+ lock_path = "#{path}.lock"
97
+ FileUtils.mkdir_p(File.dirname(lock_path))
98
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
99
+ lock.flock(mode)
100
+ yield
101
+ ensure
102
+ lock.flock(File::LOCK_UN)
103
+ end
104
+ end
105
+
106
+ # fsync the directory so the rename is durable. Best-effort: some
107
+ # platforms/filesystems refuse to open a dir for fsync (e.g. Windows),
108
+ # in which case durability of the rename degrades but correctness of the
109
+ # atomic swap is unaffected.
110
+ def fsync_dir(dir)
111
+ File.open(dir, &:fsync)
112
+ rescue StandardError
113
+ nil
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Rubino
6
+ module Util
7
+ # Consistent .gitignore-aware file filtering shared by the grep Ruby
8
+ # fallback and the glob tool (#375b/#375c).
9
+ #
10
+ # The PROBLEM: grep's ripgrep path honors .gitignore, but the Ruby fallback
11
+ # (`Dir.glob("**/*")`) did not — so `grep` returned a DIFFERENT, larger set
12
+ # (including build artifacts, node_modules, secrets in ignored files)
13
+ # depending on whether rg happened to be installed. Non-deterministic, and a
14
+ # leak of ignored content. The glob tool ignored .gitignore entirely too.
15
+ #
16
+ # The FIX: a single ignore oracle both non-rg paths consult, matching rg's
17
+ # default semantics as closely as a non-rg implementation can:
18
+ #
19
+ # * In a git repo, the canonical answer is `git ls-files --cached --others
20
+ # --exclude-standard` (tracked + untracked-but-not-ignored) — EXACTLY
21
+ # the set rg searches by default. We build the allowed-path set from it.
22
+ # * Outside a repo (or if git fails), fall back to a small built-in
23
+ # denylist of always-noise dirs (.git, node_modules, …) so behaviour is
24
+ # still deterministic and never leaks the VCS internals.
25
+ #
26
+ # Per-root results are cached for the life of the instance, so a single
27
+ # grep/glob call pays the git cost once.
28
+ class IgnoreRules
29
+ # Always-skipped dirs for the non-git fallback. Mirrors the set
30
+ # UI::CompletionSource uses so discovery is consistent across the tool.
31
+ FALLBACK_IGNORE_DIRS = %w[.git node_modules vendor tmp log .bundle .svn .hg __pycache__].freeze
32
+
33
+ def initialize
34
+ @allowed_cache = {}
35
+ @git_root_cache = {}
36
+ end
37
+
38
+ # True when +abs_path+ (an absolute file path) should be EXCLUDED from
39
+ # results given +root+ (the search base). Consistent across grep-fallback
40
+ # and glob: a path git ignores (or the fallback denylist matches) is
41
+ # ignored regardless of whether rg is installed.
42
+ def ignored?(abs_path, root)
43
+ allowed = allowed_set(root)
44
+ return fallback_ignored?(abs_path, root) if allowed.nil?
45
+
46
+ rel = relative(abs_path, root)
47
+ return true if rel.nil?
48
+
49
+ !allowed.include?(rel)
50
+ end
51
+
52
+ private
53
+
54
+ # The set of NON-ignored relative paths under +root+ per git, or nil when
55
+ # this isn't a git repo / git failed (caller falls back to the denylist).
56
+ # git ls-files is run from the repo root and paths are re-based onto +root+
57
+ # so a search rooted in a subdir still matches.
58
+ def allowed_set(root)
59
+ @allowed_cache.fetch(root) do
60
+ @allowed_cache[root] = build_allowed_set(root)
61
+ end
62
+ end
63
+
64
+ def build_allowed_set(root)
65
+ repo_root = git_root(root)
66
+ return nil if repo_root.nil?
67
+
68
+ out, status = Open3.capture2(
69
+ "git", "ls-files", "--cached", "--others", "--exclude-standard", "-z",
70
+ chdir: repo_root, err: File::NULL
71
+ )
72
+ return nil unless status.success?
73
+
74
+ set = Set.new
75
+ out.split("\0").each do |rel_to_repo|
76
+ next if rel_to_repo.empty?
77
+
78
+ abs = File.expand_path(rel_to_repo, repo_root)
79
+ rel = relative(abs, root)
80
+ set << rel if rel
81
+ end
82
+ set
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ # The git toplevel for +root+, or nil if not a repo. Cached per root.
88
+ def git_root(root)
89
+ @git_root_cache.fetch(root) do
90
+ out, status = Open3.capture2(
91
+ "git", "rev-parse", "--show-toplevel",
92
+ chdir: root, err: File::NULL
93
+ )
94
+ @git_root_cache[root] = status.success? ? out.strip : nil
95
+ end
96
+ rescue StandardError
97
+ @git_root_cache[root] = nil
98
+ end
99
+
100
+ # Non-git fallback: ignore by built-in noise-dir denylist on any path
101
+ # component, so behaviour stays deterministic without a repo.
102
+ def fallback_ignored?(abs_path, root)
103
+ rel = relative(abs_path, root) || File.basename(abs_path)
104
+ rel.split(File::SEPARATOR).any? { |part| FALLBACK_IGNORE_DIRS.include?(part) }
105
+ end
106
+
107
+ # Path of +abs_path+ relative to +root+, or nil when it's outside +root+.
108
+ def relative(abs_path, root)
109
+ abs = File.expand_path(abs_path)
110
+ base = File.expand_path(root)
111
+ return File.basename(abs) if abs == base
112
+
113
+ prefix = "#{base}#{File::SEPARATOR}"
114
+ return nil unless abs.start_with?(prefix)
115
+
116
+ abs[prefix.length..]
117
+ end
118
+ end
119
+ end
120
+ end