rubik 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/lib/rubik/buffer/manager.rb +21 -3
- data/lib/rubik/config/defaults.rb +16 -20
- data/lib/rubik/config.rb +0 -6
- data/lib/rubik/llm/client.rb +102 -72
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
- data/lib/rubik/platform/process.rb +6 -6
- data/lib/rubik/platform/sanitize.rb +37 -16
- data/lib/rubik/prompt/doctrine.rb +4 -0
- data/lib/rubik/repl/commands/agent_execution.rb +100 -87
- data/lib/rubik/repl/commands/agent_providers.rb +190 -156
- data/lib/rubik/repl/commands/lsp.rb +212 -179
- data/lib/rubik/repl/commands/workspace.rb +156 -152
- data/lib/rubik/runtime/overlay_storage.rb +59 -2
- data/lib/rubik/sandbox/driver/docker.rb +2 -2
- data/lib/rubik/session/replayer.rb +1 -2
- data/lib/rubik/storage/store.rb +3 -2
- data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 220e106421fc0a5692890a2c3f3e7ba2296661c5ce60a96f62a3f8d5fcaa4c0a
|
|
4
|
+
data.tar.gz: 1a287e603bb553b11ebc1e0187f4a0d00e8e6b17a68c005311bc144635a1696a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c02dbe25a9cc8a3af6a4c24187fc9115ad8ae5077c49e4842e2f03151ade01bedc4cc5e4709056a388a3934151b4a361422c30b1de92158e3d4c6d6d87c56743
|
|
7
|
+
data.tar.gz: db9835fd0913e3fad74ab68bdbd25137df95544579b2beed718237d09699e378fb2cbfde7da9341785f407d3aea9a43b98aedec34df1305278b93ddc3434adb6
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,54 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
10
10
|
|
|
11
11
|
Nothing yet.
|
|
12
12
|
|
|
13
|
+
## [0.3.4] - 2026-10-23
|
|
14
|
+
|
|
15
|
+
A security-hardening patch: three injection/traversal findings in
|
|
16
|
+
child-process enumeration, the docker sandbox driver and overlay
|
|
17
|
+
storage are closed, scan-heavy paths got faster, and a wave of
|
|
18
|
+
oversized REPL/LLM methods was decomposed with no behavior change.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **Command injection in child-process enumeration (#238):**
|
|
23
|
+
`direct_child_pids` interpolated the target pid into backtick
|
|
24
|
+
shells (`pgrep -P #{pid}` / `ps --ppid #{pid}`); both lookups now
|
|
25
|
+
run through array-parameterized `Open3.capture2` calls, so pid
|
|
26
|
+
values never reach a shell for re-parsing.
|
|
27
|
+
- **Command injection in the docker sandbox driver (#237):**
|
|
28
|
+
`DockerDriver#available?` and container cleanup built command
|
|
29
|
+
strings with interpolation (`docker rm -f #{container_name}`);
|
|
30
|
+
both now use array-form `system`, keeping shell metacharacters
|
|
31
|
+
inert.
|
|
32
|
+
- **Path traversal in OverlayStorage (#232):** overlay directories
|
|
33
|
+
are validated against an allowlist of the four known overlay dirs
|
|
34
|
+
resolved under their trusted roots, manifests and every
|
|
35
|
+
command/tool write path are containment-checked, and traversal
|
|
36
|
+
attempts are rejected loudly with a `[SECURITY]` warning instead
|
|
37
|
+
of escaping the overlay root.
|
|
38
|
+
|
|
39
|
+
### Performance
|
|
40
|
+
|
|
41
|
+
- **Faster scanning internals (#220, #225, #227, #228, #244):**
|
|
42
|
+
storage indexing parses JSON lines with less string churn;
|
|
43
|
+
`Session::Replayer` streams with `File.foreach`;
|
|
44
|
+
`Buffer::Manager#all_files` prunes `.git` traversal;
|
|
45
|
+
`FuzzyMatcher#find_closest_subsequence` tightens its inner loop;
|
|
46
|
+
and `clean_utf8_deep` gains an allocation-free fast path
|
|
47
|
+
(Symbol#name validation, Hash-based cycle tracking, ~3.3x on
|
|
48
|
+
clean input). `Buffer::Manager#diff_all` also reads through
|
|
49
|
+
`Platform.read_text`, so on-disk content is UTF-8-tagged
|
|
50
|
+
consistently (#226).
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- **No-behavior-change decompositions (#231, #233, #234, #235,
|
|
55
|
+
#239, #240, #242, #243):** oversized REPL command registrations
|
|
56
|
+
(workspace allow-list, goal, agent providers, allow-cmd, LSP),
|
|
57
|
+
the LLM client's `post_json`, `GoalLedger::Patch#normalize_op`
|
|
58
|
+
and config `Defaults` scalar validation were split into helper
|
|
59
|
+
methods — same behavior, reviewable units.
|
|
60
|
+
|
|
13
61
|
## [0.3.3] - 2026-10-21
|
|
14
62
|
|
|
15
63
|
A field-incident patch: subagent runs get a lifetime step ceiling,
|
data/lib/rubik/buffer/manager.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "find"
|
|
4
5
|
require "digest"
|
|
5
6
|
require "pathname"
|
|
6
7
|
require "monitor"
|
|
@@ -265,10 +266,27 @@ module Rubik
|
|
|
265
266
|
# for callers that only need a fuzzy-match candidate list.
|
|
266
267
|
def all_files
|
|
267
268
|
buffer_keys = @monitor.synchronize { @buffer.keys }
|
|
269
|
+
return buffer_keys unless File.directory?(@root_dir)
|
|
270
|
+
|
|
268
271
|
disk_files = begin
|
|
269
|
-
|
|
270
|
-
|
|
272
|
+
files = []
|
|
273
|
+
root_size = @root_dir.bytesize
|
|
274
|
+
Find.find(@root_dir) do |path|
|
|
275
|
+
next if path.bytesize == root_size
|
|
276
|
+
|
|
277
|
+
rel = path.byteslice((root_size + 1)..-1)
|
|
278
|
+
rel = rel.tr("\\", "/") if File::ALT_SEPARATOR
|
|
279
|
+
|
|
280
|
+
if rel.start_with?(".git")
|
|
281
|
+
Find.prune if File.directory?(path)
|
|
282
|
+
next
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
next if File.directory?(path)
|
|
286
|
+
|
|
287
|
+
files << rel
|
|
271
288
|
end
|
|
289
|
+
files
|
|
272
290
|
rescue SystemCallError
|
|
273
291
|
[]
|
|
274
292
|
end
|
|
@@ -282,7 +300,7 @@ module Rubik
|
|
|
282
300
|
|
|
283
301
|
diffs = snapshot.map do |rel_path, new_content|
|
|
284
302
|
full_path = File.expand_path(rel_path, @root_dir)
|
|
285
|
-
orig_content = File.exist?(full_path) ? Rubik::Platform.normalize_newlines(
|
|
303
|
+
orig_content = File.exist?(full_path) ? Rubik::Platform.normalize_newlines(Rubik::Platform.read_text(full_path)) : ""
|
|
286
304
|
Diff.unified_diff(rel_path, orig_content, new_content)
|
|
287
305
|
end
|
|
288
306
|
|
|
@@ -162,14 +162,10 @@ module Rubik
|
|
|
162
162
|
"maestro_gate" => true,
|
|
163
163
|
"auto_pause_on_network_error" => true,
|
|
164
164
|
"reactive_wakeup" => true,
|
|
165
|
-
# Vakanüvis arka plan kronikçisi (
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
# flush_events: damıtıcı doğumu için olay eşiği; model: nil =
|
|
170
|
-
# root provider; retention: supersede (iz bırak) | purge (sil);
|
|
171
|
-
# root_turns: REPL kök-turn'leri de annal'a yazılsın mı
|
|
172
|
-
# (root-turn planı 2026-10-04; kapalıyken abonelik kurulmaz).
|
|
165
|
+
# Vakanüvis arka plan kronikçisi (plan 2026-10-04 K1-K9): deterministik
|
|
166
|
+
# annal + wiki derleyicisi. enabled: harness kur/kurulama; distiller:
|
|
167
|
+
# Düzlem 2 LLM doğumu (bütçe: flush_events x max_steps); model: nil =
|
|
168
|
+
# root provider; retention: supersede|purge; root_turns: kök-turn annal'ı.
|
|
173
169
|
"vakanuvis" => {
|
|
174
170
|
"enabled" => true,
|
|
175
171
|
"distiller" => true,
|
|
@@ -179,18 +175,12 @@ module Rubik
|
|
|
179
175
|
"retention" => "supersede",
|
|
180
176
|
"root_turns" => true
|
|
181
177
|
},
|
|
182
|
-
# Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi
|
|
183
|
-
# DEFAULT ON (kullanıcı kararı 2026-10-10
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
# trigger_ratio: turn sonu pencere tahmini girdi tavanının bu
|
|
189
|
-
# oranına ulaşınca LLM budayıcı doğar (0.85'lik kör trim'den ÖNCE
|
|
190
|
-
# anlamsal sıkıştırma); target_ratio: plan yalnızca sonuç bu
|
|
191
|
-
# orana sığıyorsa uygulanır; max_steps/model: budayıcı bütçesi
|
|
192
|
-
# (nil = root provider); min_turns_between: otomatik doğum oranı
|
|
193
|
-
# sınırlayıcısı (turn sayısı).
|
|
178
|
+
# Pruner (repl/pruner): yarı deterministik KÖK pencere temizleyicisi,
|
|
179
|
+
# DEFAULT ON (kullanıcı kararı 2026-10-10; kill-switch: enabled=false).
|
|
180
|
+
# trigger_ratio: tahmini girdi tavanının bu oranında LLM budayıcı doğar
|
|
181
|
+
# (kör trim'den ÖNCE anlamsal sıkıştırma); target_ratio: plan sadece
|
|
182
|
+
# sonuca sığıyorsa uygulanır; max_steps/model: budayıcı bütçesi (nil =
|
|
183
|
+
# root provider); min_turns_between: otomatik doğum oranı sınırlayıcısı.
|
|
194
184
|
"pruner" => {
|
|
195
185
|
"enabled" => true,
|
|
196
186
|
"trigger_ratio" => 0.6,
|
|
@@ -216,6 +206,12 @@ module Rubik
|
|
|
216
206
|
|
|
217
207
|
private
|
|
218
208
|
|
|
209
|
+
def validate_prompt_grace_value!(key, value)
|
|
210
|
+
return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
|
|
211
|
+
|
|
212
|
+
raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
|
|
213
|
+
end
|
|
214
|
+
|
|
219
215
|
def validate_schema(config, schema = DEFAULT_CONFIG, prefix = "", strict: false)
|
|
220
216
|
raise ArgumentError, "Configuration must be a Hash, got #{config.class}" unless config.is_a?(Hash)
|
|
221
217
|
|
data/lib/rubik/config.rb
CHANGED
|
@@ -163,12 +163,6 @@ module Rubik
|
|
|
163
163
|
|
|
164
164
|
private
|
|
165
165
|
|
|
166
|
-
def validate_prompt_grace_value!(key, value)
|
|
167
|
-
return unless key == "ui.prompt_grace_window_ms" && !(value.is_a?(Integer) && value >= 0)
|
|
168
|
-
|
|
169
|
-
raise ArgumentError, "ui.prompt_grace_window_ms must be a non-negative integer (milliseconds)"
|
|
170
|
-
end
|
|
171
|
-
|
|
172
166
|
# save! returns itself for no-write scopes; persist outcomes are hashes.
|
|
173
167
|
def persist_result(res)
|
|
174
168
|
res.is_a?(Hash) ? res : { ok: true }
|
data/lib/rubik/llm/client.rb
CHANGED
|
@@ -234,87 +234,118 @@ module Rubik
|
|
|
234
234
|
)
|
|
235
235
|
end
|
|
236
236
|
|
|
237
|
-
def self.
|
|
238
|
-
|
|
239
|
-
on_retry: nil, http_factory: nil)
|
|
240
|
-
begin
|
|
241
|
-
uri = URI.parse(url_str.to_s)
|
|
242
|
-
rescue URI::InvalidURIError => e
|
|
243
|
-
raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
|
|
244
|
-
end
|
|
245
|
-
|
|
237
|
+
def self.parse_and_validate_uri(url_str)
|
|
238
|
+
uri = URI.parse(url_str.to_s)
|
|
246
239
|
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
247
240
|
raise ClientError, "Invalid endpoint URL '#{url_str}'. URL must start with http:// or https:// (e.g. https://api.openai.com/v1/chat/completions)"
|
|
248
241
|
end
|
|
249
242
|
|
|
243
|
+
uri
|
|
244
|
+
rescue URI::InvalidURIError => e
|
|
245
|
+
raise ClientError, "Invalid endpoint URL '#{url_str}': #{e.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def self.perform_post_request(uri, headers, payload, timeout:, open_timeout:, http_factory:)
|
|
249
|
+
if http_factory
|
|
250
|
+
http = http_factory.call(uri)
|
|
251
|
+
req = build_post_request(uri, headers, payload)
|
|
252
|
+
http.request(req)
|
|
253
|
+
else
|
|
254
|
+
with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
|
|
255
|
+
req = build_post_request(uri, headers, payload)
|
|
256
|
+
http.request(req)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Rate limiting is transitory and self-healing — it gets its own
|
|
262
|
+
# larger budget (max_429_retries), a Retry-After-aware delay, and
|
|
263
|
+
# a typed error when exhausted so callers can report "provider
|
|
264
|
+
# throttled" distinctly. The generic 5xx budget is untouched, so
|
|
265
|
+
# one 429 burst cannot exhaust the transport-failure budget.
|
|
266
|
+
def self.handle_429_retry(res, rate_limit_retries:, max_429_retries:, start_time:, backoff_base:, max_wall_clock:, on_retry:)
|
|
267
|
+
if rate_limit_retries < max_429_retries
|
|
268
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
269
|
+
delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
|
|
270
|
+
if elapsed + delay <= max_wall_clock
|
|
271
|
+
rate_limit_retries += 1
|
|
272
|
+
on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
|
|
273
|
+
sleep(delay)
|
|
274
|
+
return rate_limit_retries
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
raise rate_limit_error(res, rate_limit_retries)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def self.attempt_backoff_retry(retries:, max_retries:, start_time:, backoff_base:, max_wall_clock:, reason:, on_retry:)
|
|
281
|
+
return nil unless retries < max_retries
|
|
282
|
+
|
|
283
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
284
|
+
delay = backoff_base * (2**retries) * (0.8 + (0.4 * rand))
|
|
285
|
+
return nil if elapsed + delay > max_wall_clock
|
|
286
|
+
|
|
287
|
+
retries += 1
|
|
288
|
+
on_retry&.call(retries, max_retries, reason)
|
|
289
|
+
sleep(delay)
|
|
290
|
+
retries
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# A body without a charset arrives tagged ASCII-8BIT; embedding it
|
|
294
|
+
# raw makes every ClientError message binary and the UTF-8 output
|
|
295
|
+
# layer raises inside the REPL's rescue clause (masking the real
|
|
296
|
+
# HTTP error as Encoding::CompatibilityError).
|
|
297
|
+
# Exhausted 5xx is transport-class (provider outage) → transient;
|
|
298
|
+
# a 4xx is content/auth/moderation → permanent. One raise point
|
|
299
|
+
# for both, the flag decided by status (issue #106).
|
|
300
|
+
def self.parse_json_response(res, uri:, status:)
|
|
301
|
+
unless status >= 200 && status < 300
|
|
302
|
+
raise ClientError.new(
|
|
303
|
+
"HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
|
|
304
|
+
transient: status >= 500
|
|
305
|
+
)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
begin
|
|
309
|
+
JSON.parse(res.body)
|
|
310
|
+
rescue JSON::ParserError => e
|
|
311
|
+
raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def self.post_json(url_str, headers: {}, payload: {}, timeout: 30, open_timeout: 10,
|
|
316
|
+
max_retries: 2, max_429_retries: 5, backoff_base: 0.2, max_wall_clock: 120,
|
|
317
|
+
on_retry: nil, http_factory: nil)
|
|
318
|
+
uri = parse_and_validate_uri(url_str)
|
|
250
319
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
251
320
|
retries = 0
|
|
252
321
|
rate_limit_retries = 0
|
|
253
322
|
|
|
254
323
|
loop do
|
|
255
|
-
res =
|
|
256
|
-
if http_factory
|
|
257
|
-
http = http_factory.call(uri)
|
|
258
|
-
req = build_post_request(uri, headers, payload)
|
|
259
|
-
http.request(req)
|
|
260
|
-
else
|
|
261
|
-
with_pooled_connection(uri, timeout: timeout, open_timeout: open_timeout) do |http|
|
|
262
|
-
req = build_post_request(uri, headers, payload)
|
|
263
|
-
http.request(req)
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
|
|
324
|
+
res = perform_post_request(uri, headers, payload, timeout: timeout, open_timeout: open_timeout, http_factory: http_factory)
|
|
267
325
|
status = res.code.to_i
|
|
268
326
|
|
|
269
327
|
if status == 429
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
277
|
-
delay = retry_delay_after(res, rate_limit_retries + 1, backoff_base)
|
|
278
|
-
if elapsed + delay <= max_wall_clock
|
|
279
|
-
rate_limit_retries += 1
|
|
280
|
-
on_retry&.call(rate_limit_retries, max_429_retries, "HTTP 429")
|
|
281
|
-
sleep(delay)
|
|
282
|
-
next
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
raise rate_limit_error(res, rate_limit_retries)
|
|
328
|
+
rate_limit_retries = handle_429_retry(
|
|
329
|
+
res, rate_limit_retries: rate_limit_retries, max_429_retries: max_429_retries,
|
|
330
|
+
start_time: start_time, backoff_base: backoff_base, max_wall_clock: max_wall_clock,
|
|
331
|
+
on_retry: on_retry
|
|
332
|
+
)
|
|
333
|
+
next
|
|
286
334
|
end
|
|
287
335
|
|
|
288
|
-
if
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
336
|
+
if status >= 500
|
|
337
|
+
new_retries = attempt_backoff_retry(
|
|
338
|
+
retries: retries, max_retries: max_retries, start_time: start_time,
|
|
339
|
+
backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: "HTTP #{status}",
|
|
340
|
+
on_retry: on_retry
|
|
341
|
+
)
|
|
342
|
+
if new_retries
|
|
343
|
+
retries = new_retries
|
|
295
344
|
next
|
|
296
345
|
end
|
|
297
346
|
end
|
|
298
347
|
|
|
299
|
-
|
|
300
|
-
# A body without a charset arrives tagged ASCII-8BIT; embedding it
|
|
301
|
-
# raw makes every ClientError message binary and the UTF-8 output
|
|
302
|
-
# layer raises inside the REPL's rescue clause (masking the real
|
|
303
|
-
# HTTP error as Encoding::CompatibilityError).
|
|
304
|
-
# Exhausted 5xx is transport-class (provider outage) → transient;
|
|
305
|
-
# a 4xx is content/auth/moderation → permanent. One raise point
|
|
306
|
-
# for both, the flag decided by status (issue #106).
|
|
307
|
-
raise ClientError.new(
|
|
308
|
-
"HTTP Request failed with code #{res.code}: #{Rubik::Platform.safe_utf8(res.body)}",
|
|
309
|
-
transient: status >= 500
|
|
310
|
-
)
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
begin
|
|
314
|
-
return JSON.parse(res.body)
|
|
315
|
-
rescue JSON::ParserError => e
|
|
316
|
-
raise ClientError, "Invalid JSON response from #{uri.host} (HTTP #{status}): #{e.message}"
|
|
317
|
-
end
|
|
348
|
+
return parse_json_response(res, uri: uri, status: status)
|
|
318
349
|
rescue ClientError
|
|
319
350
|
raise
|
|
320
351
|
rescue Net::ReadTimeout
|
|
@@ -323,15 +354,14 @@ module Rubik
|
|
|
323
354
|
rescue Interrupt
|
|
324
355
|
raise # rubocop:disable Lint/DuplicateBranch -- Ctrl+C yayilimi bilinçli olarak ayri tutuldu, even mid-backoff
|
|
325
356
|
rescue *RETRYABLE_ERRORS => e
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
end
|
|
357
|
+
new_retries = attempt_backoff_retry(
|
|
358
|
+
retries: retries, max_retries: max_retries, start_time: start_time,
|
|
359
|
+
backoff_base: backoff_base, max_wall_clock: max_wall_clock, reason: e.class.name,
|
|
360
|
+
on_retry: on_retry
|
|
361
|
+
)
|
|
362
|
+
if new_retries
|
|
363
|
+
retries = new_retries
|
|
364
|
+
next
|
|
335
365
|
end
|
|
336
366
|
raise ClientError.new(
|
|
337
367
|
"Network error calling #{uri.host}: #{e.class} #{e.message} (gave up after #{retries} retr#{retries == 1 ? 'y' : 'ies'})",
|
|
@@ -121,81 +121,98 @@ module Rubik
|
|
|
121
121
|
end
|
|
122
122
|
|
|
123
123
|
def self.normalize_op(raw, idx)
|
|
124
|
-
|
|
124
|
+
err = validate_op_keys(raw, idx)
|
|
125
|
+
return [nil, [err]] if err
|
|
126
|
+
|
|
127
|
+
verb, path, err = validate_op_verb_and_path(raw, idx)
|
|
128
|
+
return [nil, [err]] if err
|
|
129
|
+
|
|
130
|
+
tokens, err = tokenize(path, idx)
|
|
131
|
+
return [nil, [err]] if err
|
|
132
|
+
|
|
133
|
+
err = validate_op_value_and_hyphen(raw, verb, path, tokens, idx)
|
|
134
|
+
return [nil, [err]] if err
|
|
135
|
+
|
|
136
|
+
value = verb == "remove" ? nil : deep_freeze(raw["value"])
|
|
137
|
+
[{ op: verb.to_sym, path: path, tokens: tokens, value: value, index: idx }.freeze, []]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.validate_op_keys(raw, idx)
|
|
141
|
+
return op_shape_reason(idx) unless raw.is_a?(Hash)
|
|
125
142
|
|
|
126
143
|
unknown = raw.keys - OP_KEYS
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
return nil if unknown.empty?
|
|
145
|
+
|
|
146
|
+
GoalLedger.reason(
|
|
147
|
+
:schema, op_index: idx, path: "",
|
|
148
|
+
message: "op #{idx} carries unknown key(s) #{unknown.join(', ')}",
|
|
149
|
+
hint: "allowed op keys: op, path, value"
|
|
150
|
+
)
|
|
151
|
+
end
|
|
134
152
|
|
|
153
|
+
def self.validate_op_verb_and_path(raw, idx)
|
|
135
154
|
verb = raw["op"]
|
|
136
155
|
unless OP_VERBS.include?(verb)
|
|
137
|
-
return
|
|
156
|
+
return nil, nil, GoalLedger.reason(
|
|
138
157
|
:schema, op_index: idx, path: "",
|
|
139
158
|
message: "op #{idx} has unknown verb #{GoalLedger.json_type_name(verb)}#{verb_inspect(verb)}",
|
|
140
159
|
hint: "allowed verbs: add, replace, remove"
|
|
141
|
-
)
|
|
160
|
+
)
|
|
142
161
|
end
|
|
143
162
|
|
|
144
163
|
path = raw["path"]
|
|
145
164
|
unless path.is_a?(String)
|
|
146
|
-
return
|
|
165
|
+
return nil, nil, GoalLedger.reason(
|
|
147
166
|
:schema, op_index: idx, path: "",
|
|
148
167
|
message: "op #{idx} path must be a JSON string (got #{GoalLedger.json_type_name(path)})",
|
|
149
168
|
hint: 'address sections with JSON pointers like "/tasks/3/status" or "/findings/-"'
|
|
150
|
-
)
|
|
169
|
+
)
|
|
151
170
|
end
|
|
152
171
|
|
|
153
172
|
unless path.start_with?("/")
|
|
154
|
-
return
|
|
173
|
+
return nil, nil, GoalLedger.reason(
|
|
155
174
|
:schema, op_index: idx, path: path,
|
|
156
175
|
message: "op #{idx} path '#{path}' is not a JSON pointer",
|
|
157
176
|
hint: 'pointers start with "/" — use /section, /section/idx, /section/idx/field or /section/-'
|
|
158
|
-
)
|
|
177
|
+
)
|
|
159
178
|
end
|
|
160
179
|
|
|
161
180
|
if path == "/"
|
|
162
|
-
return
|
|
181
|
+
return nil, nil, GoalLedger.reason(
|
|
163
182
|
:schema, op_index: idx, path: path,
|
|
164
183
|
message: "op #{idx} addresses the whole ledger root",
|
|
165
|
-
hint: "the ledger root is not replaceable; address sections individually "
|
|
166
|
-
|
|
167
|
-
)]]
|
|
184
|
+
hint: "the ledger root is not replaceable; address sections individually (/tasks/3/status, /findings/-, ...)"
|
|
185
|
+
)
|
|
168
186
|
end
|
|
169
187
|
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
[verb, path, nil]
|
|
189
|
+
end
|
|
172
190
|
|
|
191
|
+
def self.validate_op_value_and_hyphen(raw, verb, path, tokens, idx)
|
|
173
192
|
if verb == "remove" && raw.key?("value")
|
|
174
|
-
return
|
|
193
|
+
return GoalLedger.reason(
|
|
175
194
|
:schema, op_index: idx, path: path,
|
|
176
195
|
message: "op #{idx} (remove) must not carry a value",
|
|
177
196
|
hint: "to change a value use replace; remove deletes the item outright"
|
|
178
|
-
)
|
|
197
|
+
)
|
|
179
198
|
end
|
|
180
199
|
unless verb == "remove" || raw.key?("value")
|
|
181
|
-
return
|
|
200
|
+
return GoalLedger.reason(
|
|
182
201
|
:schema, op_index: idx, path: path,
|
|
183
202
|
message: "op #{idx} (#{verb}) is missing its value",
|
|
184
203
|
hint: 'carry the value in the op: {"op": "add", "path": "/findings/-", "value": ...}'
|
|
185
|
-
)
|
|
204
|
+
)
|
|
186
205
|
end
|
|
187
206
|
|
|
188
207
|
if hyphen_violation(tokens, verb)
|
|
189
|
-
return
|
|
208
|
+
return GoalLedger.reason(
|
|
190
209
|
:schema, op_index: idx, path: path,
|
|
191
210
|
message: "op #{idx} path '#{path}' uses '-' outside an append position",
|
|
192
|
-
hint: "'-' appends and may only be the final token of an add path "
|
|
193
|
-
|
|
194
|
-
)]]
|
|
211
|
+
hint: "'-' appends and may only be the final token of an add path (e.g. /findings/-); use a numeric index for existing items"
|
|
212
|
+
)
|
|
195
213
|
end
|
|
196
214
|
|
|
197
|
-
|
|
198
|
-
[{ op: verb.to_sym, path: path, tokens: tokens, value: value, index: idx }.freeze, []]
|
|
215
|
+
nil
|
|
199
216
|
end
|
|
200
217
|
|
|
201
218
|
def self.op_shape_reason(idx)
|
|
@@ -261,6 +278,7 @@ module Rubik
|
|
|
261
278
|
end
|
|
262
279
|
|
|
263
280
|
private_class_method :header_reasons, :normalize_op, :op_shape_reason, :verb_inspect,
|
|
281
|
+
:validate_op_keys, :validate_op_verb_and_path, :validate_op_value_and_hyphen,
|
|
264
282
|
:tokenize, :hyphen_violation, :deep_freeze
|
|
265
283
|
end
|
|
266
284
|
end
|
|
@@ -114,8 +114,8 @@ module Rubik
|
|
|
114
114
|
@instances[inst.id] = inst
|
|
115
115
|
activate_instance!(inst, child_ctx: child_ctx, owner_token: eff_owner)
|
|
116
116
|
end
|
|
117
|
-
# SS bırakma kancası: redeploy da bir başlangıçtır — taze :running
|
|
118
|
-
# flip'i, bu id'yi SS ile bekleyen kuyruk spesifikasyonlarını
|
|
117
|
+
# Start-to-Start (SS) bırakma kancası: redeploy da bir başlangıçtır — taze :running
|
|
118
|
+
# flip'i, bu id'yi Start-to-Start (SS) ile bekleyen kuyruk spesifikasyonlarını
|
|
119
119
|
# bırakır (kilit DIŞINDA — emit kuralı; spawn ile aynı düzen).
|
|
120
120
|
release_ready!
|
|
121
121
|
redeployed
|
|
@@ -162,17 +162,17 @@ module Rubik
|
|
|
162
162
|
return pids.reject(&:zero?) unless pids.empty?
|
|
163
163
|
end
|
|
164
164
|
|
|
165
|
-
out = begin
|
|
166
|
-
|
|
165
|
+
out, = begin
|
|
166
|
+
Open3.capture2("pgrep", "-P", pid.to_s, err: File::NULL)
|
|
167
167
|
rescue StandardError
|
|
168
|
-
nil
|
|
168
|
+
[nil, nil]
|
|
169
169
|
end
|
|
170
170
|
return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
|
|
171
171
|
|
|
172
|
-
out = begin
|
|
173
|
-
|
|
172
|
+
out, = begin
|
|
173
|
+
Open3.capture2("ps", "--ppid", pid.to_s, "-o", "pid=", err: File::NULL)
|
|
174
174
|
rescue StandardError
|
|
175
|
-
nil
|
|
175
|
+
[nil, nil]
|
|
176
176
|
end
|
|
177
177
|
return out.lines.map(&:to_i).reject(&:zero?) if out && !out.empty?
|
|
178
178
|
|
|
@@ -59,46 +59,67 @@ module Rubik
|
|
|
59
59
|
retagged.valid_encoding? ? retagged : retagged.scrub("")
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
#
|
|
62
|
+
# PERF: Fast-path deep UTF-8 cleaner. Performs zero-allocation Symbol
|
|
63
|
+
# validation via Symbol#name and tracks cycle object IDs in a Hash to
|
|
64
|
+
# eliminate Set instance allocations (~3.3x speedup, ~70% faster).
|
|
63
65
|
def clean_utf8_deep(obj, seen = nil)
|
|
64
66
|
case obj
|
|
65
67
|
when String
|
|
66
68
|
clean_utf8(obj)
|
|
69
|
+
when Symbol
|
|
70
|
+
fast_clean_symbol(obj)
|
|
67
71
|
when Hash
|
|
68
|
-
seen ||=
|
|
69
|
-
|
|
72
|
+
seen ||= {}
|
|
73
|
+
obj_id = obj.object_id
|
|
74
|
+
return "{...}" if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
|
|
70
75
|
|
|
71
|
-
seen.add(
|
|
76
|
+
seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
res = {}
|
|
79
|
+
obj.each do |k, v|
|
|
80
|
+
cleaned_k = if k.is_a?(Symbol)
|
|
81
|
+
fast_clean_symbol(k)
|
|
82
|
+
elsif k.is_a?(String)
|
|
75
83
|
clean_utf8(k)
|
|
76
84
|
else
|
|
77
|
-
|
|
85
|
+
clean_utf8_deep(k, seen)
|
|
78
86
|
end
|
|
79
|
-
|
|
87
|
+
res[cleaned_k] = clean_utf8_deep(v, seen)
|
|
80
88
|
end
|
|
89
|
+
res
|
|
81
90
|
when Array
|
|
82
|
-
seen ||=
|
|
83
|
-
|
|
91
|
+
seen ||= {}
|
|
92
|
+
obj_id = obj.object_id
|
|
93
|
+
return "[...]" if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
|
|
84
94
|
|
|
85
|
-
seen.add(
|
|
95
|
+
seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
|
|
86
96
|
|
|
87
97
|
obj.map { |item| clean_utf8_deep(item, seen) }
|
|
88
98
|
when Set
|
|
89
|
-
seen ||=
|
|
90
|
-
|
|
99
|
+
seen ||= {}
|
|
100
|
+
obj_id = obj.object_id
|
|
101
|
+
return Set.new(["[...]"]) if seen.is_a?(Hash) ? seen.key?(obj_id) : seen.include?(obj_id)
|
|
91
102
|
|
|
92
|
-
seen.add(
|
|
103
|
+
seen.is_a?(Hash) ? (seen[obj_id] = true) : seen.add(obj_id)
|
|
93
104
|
|
|
94
105
|
obj.to_set { |item| clean_utf8_deep(item, seen) }
|
|
95
|
-
when Symbol
|
|
96
|
-
clean_utf8(obj.to_s).to_sym
|
|
97
106
|
else
|
|
98
107
|
obj
|
|
99
108
|
end
|
|
100
109
|
end
|
|
101
110
|
|
|
111
|
+
# Binary (ASCII-8BIT) symbol names always report valid_encoding?, so a
|
|
112
|
+
# bare validity check would fast-path them through UNCLEANED — the old
|
|
113
|
+
# clean_utf8 path strips their >= 0x80 bytes and the byte loss would
|
|
114
|
+
# instead surface later, at the JSON/display layer. Only UTF-8 (or the
|
|
115
|
+
# lossless US-ASCII subset) symbols may take the fast path.
|
|
116
|
+
def fast_clean_symbol(sym)
|
|
117
|
+
name = sym.name
|
|
118
|
+
return sym if [Encoding::UTF_8, Encoding::US_ASCII].include?(name.encoding) && name.valid_encoding?
|
|
119
|
+
|
|
120
|
+
clean_utf8(name).to_sym
|
|
121
|
+
end
|
|
122
|
+
|
|
102
123
|
# Generates JSON safely without raising JSON::GeneratorError on malformed UTF-8
|
|
103
124
|
def safe_json_generate(obj, *)
|
|
104
125
|
cleaned = clean_utf8_deep(obj)
|