graphmind 0.6.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 +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE +21 -0
- data/README.md +464 -0
- data/lib/graphmind/api.rb +267 -0
- data/lib/graphmind/clock.rb +49 -0
- data/lib/graphmind/edit_guard.rb +87 -0
- data/lib/graphmind/env.rb +83 -0
- data/lib/graphmind/errors.rb +40 -0
- data/lib/graphmind/gate_engine.rb +282 -0
- data/lib/graphmind/held_ledger.rb +209 -0
- data/lib/graphmind/ids.rb +52 -0
- data/lib/graphmind/integrations/ruby_llm.rb +449 -0
- data/lib/graphmind/integrations/ruby_openai.rb +388 -0
- data/lib/graphmind/integrations/support.rb +459 -0
- data/lib/graphmind/jack.rb +201 -0
- data/lib/graphmind/loop_guard.rb +516 -0
- data/lib/graphmind/protocol.rb +123 -0
- data/lib/graphmind/railtie.rb +66 -0
- data/lib/graphmind/redaction.rb +781 -0
- data/lib/graphmind/ring_buffer.rb +75 -0
- data/lib/graphmind/runtime.rb +50 -0
- data/lib/graphmind/safe.rb +48 -0
- data/lib/graphmind/session.rb +1180 -0
- data/lib/graphmind/shrink.rb +787 -0
- data/lib/graphmind/token_batcher.rb +84 -0
- data/lib/graphmind/transport.rb +331 -0
- data/lib/graphmind/version.rb +5 -0
- data/lib/graphmind/websocket.rb +448 -0
- data/lib/graphmind/wrap.rb +295 -0
- data/lib/graphmind.rb +54 -0
- metadata +83 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "loop_guard"
|
|
5
|
+
|
|
6
|
+
module Graphmind
|
|
7
|
+
# The payload budget of the GraphMind protocol, and the one algorithm that
|
|
8
|
+
# enforces it: a port of packages/schema/src/shrink.ts (SHRINK-V2), pinned
|
|
9
|
+
# byte for byte by packages/schema/test/fixtures/shrink.json.
|
|
10
|
+
#
|
|
11
|
+
# A single embedding array or scraped page can be enormous. Anything whose
|
|
12
|
+
# payload JSON is over MAX_PAYLOAD_BYTES of UTF-8 is degraded to a
|
|
13
|
+
# type-preserving preview so one event cannot blow the server's frame cap,
|
|
14
|
+
# evict the replay buffer, or vanish. With the event type, a VALID event of a
|
|
15
|
+
# known type always shrinks to a valid event within budget (for any
|
|
16
|
+
# max_bytes >= 4096), so its node finishes in the viewer instead of hanging
|
|
17
|
+
# "running".
|
|
18
|
+
#
|
|
19
|
+
# UNITS — they must match the TypeScript reference exactly:
|
|
20
|
+
# * the budget check and every top-level `bytes` are UTF-8 BYTES of the
|
|
21
|
+
# payload's JSON;
|
|
22
|
+
# * field sizes, a shrunk field's own `bytes`, every preview and every cut
|
|
23
|
+
# string count UTF-16 CODE UNITS (an astral character is 2). A cut can
|
|
24
|
+
# split a surrogate pair; Ruby cannot hold a lone surrogate in a valid
|
|
25
|
+
# UTF-8 String, so the half is kept as its 3-byte generalised-UTF-8 form
|
|
26
|
+
# ("WTF-8", e.g. "\xED\xA0\xBD") in a UTF-8 String whose
|
|
27
|
+
# `valid_encoding?` is false, and #stringify writes it as the escape
|
|
28
|
+
# JavaScript writes (\ud83d). `bytesize` of that form is 3, exactly
|
|
29
|
+
# what JavaScript counts for a lone surrogate.
|
|
30
|
+
# * key caps keep the first keys in Hash (insertion) order. JavaScript
|
|
31
|
+
# enumerates integer-like keys first; the fixture never mixes the two in
|
|
32
|
+
# one object, and a real payload that does may keep different keys per
|
|
33
|
+
# language (the result is valid and bounded either way).
|
|
34
|
+
#
|
|
35
|
+
# JSON text is byte-identical to JavaScript's JSON.stringify for JSON-shaped
|
|
36
|
+
# values: Hash (String or Symbol keys), Array, String, Symbol (its name),
|
|
37
|
+
# Integer, Float (ECMAScript number formatting; non-finite -> null; integers
|
|
38
|
+
# beyond 2**53 as the double JavaScript would parse), true, false, nil.
|
|
39
|
+
# Anything else — another object, a String whose bytes are not UTF-8, a
|
|
40
|
+
# cycle, nesting deeper than MAX_JSON_DEPTH — is "unserializable" and takes
|
|
41
|
+
# the same path JSON.stringify throwing takes in TypeScript.
|
|
42
|
+
#
|
|
43
|
+
# TIERS of serialize_payload(payload, max_bytes, type):
|
|
44
|
+
# 1. JSON within max_bytes -> returned unchanged (same object).
|
|
45
|
+
# 2. A Hash with at most MAX_TRIM_FIELDS top-level fields -> the field
|
|
46
|
+
# trim; with a known type the result must also validate.
|
|
47
|
+
# 3. A known type and a Hash -> the skeleton (schema fields only).
|
|
48
|
+
# 4. Otherwise -> the whole-payload marker {__graphmindTruncated, bytes, preview}.
|
|
49
|
+
#
|
|
50
|
+
# Pure functions: never mutates its input, keeps no state, thread-safe.
|
|
51
|
+
module Shrink
|
|
52
|
+
# Largest payload, as UTF-8 bytes of its JSON, stored or sent unchanged.
|
|
53
|
+
MAX_PAYLOAD_BYTES = 512 * 1024
|
|
54
|
+
# Length, in UTF-16 code units, of every preview and shortened string.
|
|
55
|
+
PREVIEW_CHARS = 2000
|
|
56
|
+
# Appended to a string that had to be cut short.
|
|
57
|
+
TRUNCATION_SUFFIX = "\xE2\x80\xA6[graphmind: truncated]".dup.force_encoding(Encoding::UTF_8).freeze
|
|
58
|
+
# How deep the field shrink recurses before it marks the subtree.
|
|
59
|
+
MAX_SHRINK_DEPTH = 6
|
|
60
|
+
# Most keys the field shrink keeps from any one object.
|
|
61
|
+
MAX_SHRINK_KEYS = 256
|
|
62
|
+
# Most top-level fields a payload may have for the field trim (tier 2).
|
|
63
|
+
MAX_TRIM_FIELDS = 4096
|
|
64
|
+
# Longest string, in UTF-16 units, the skeleton keeps; then its last attempt.
|
|
65
|
+
SKELETON_CHARS = 256
|
|
66
|
+
SKELETON_MIN_CHARS = 32
|
|
67
|
+
# Nesting the serializer walks before calling a value unserializable
|
|
68
|
+
# (JavaScript's limit is its stack; Ruby's must not be a SystemStackError).
|
|
69
|
+
MAX_JSON_DEPTH = 1000
|
|
70
|
+
|
|
71
|
+
MAX_SAFE_INTEGER = (2**53) - 1
|
|
72
|
+
|
|
73
|
+
# The skeleton plan of every known event type: its schema keys in
|
|
74
|
+
# declaration order. nil = required; a Hash = a required loose object with
|
|
75
|
+
# its own plan; :optional = kept only when a string, number or boolean.
|
|
76
|
+
# Asserted equal to the fixture's `skeletonPlans` by test/test_shrink.rb.
|
|
77
|
+
PLANS = {
|
|
78
|
+
"run.started" => { "app" => nil, "sdk" => { "name" => nil, "version" => nil }.freeze, "meta" => :optional },
|
|
79
|
+
"run.finished" => { "status" => nil, "error" => :optional },
|
|
80
|
+
"graph.hint" => { "nodes" => nil },
|
|
81
|
+
"node.started" => {
|
|
82
|
+
"nodeId" => nil, "parentId" => :optional, "kind" => nil, "name" => nil, "instanceId" => nil,
|
|
83
|
+
"input" => :optional, "collapsed" => :optional, "redaction" => :optional
|
|
84
|
+
},
|
|
85
|
+
"node.token" => { "nodeId" => nil, "deltas" => nil, "redaction" => :optional },
|
|
86
|
+
"node.finished" => {
|
|
87
|
+
"nodeId" => nil, "instanceId" => :optional, "output" => :optional, "usage" => :optional,
|
|
88
|
+
"durationMs" => nil, "heldMs" => :optional, "status" => nil, "redaction" => :optional
|
|
89
|
+
},
|
|
90
|
+
"node.error" => {
|
|
91
|
+
"nodeId" => nil, "instanceId" => :optional,
|
|
92
|
+
"error" => { "name" => nil, "message" => nil, "stack" => :optional }.freeze, "heldMs" => :optional
|
|
93
|
+
},
|
|
94
|
+
"exec.paused" => {
|
|
95
|
+
"pauseId" => nil, "nodeId" => nil, "point" => nil, "reason" => :optional, "loop" => :optional,
|
|
96
|
+
"smart" => :optional, "editable" => :optional, "instanceId" => :optional
|
|
97
|
+
},
|
|
98
|
+
"exec.refused" => { "pauseId" => nil, "code" => nil, "message" => :optional, "requestId" => :optional },
|
|
99
|
+
"exec.resumed" => {
|
|
100
|
+
"pauseId" => nil, "action" => nil, "edited" => :optional, "requestId" => :optional, "principal" => :optional
|
|
101
|
+
}
|
|
102
|
+
}.transform_values(&:freeze).freeze
|
|
103
|
+
|
|
104
|
+
# Raised internally when a value has no JSON text (see the module comment).
|
|
105
|
+
class Unserializable < StandardError; end
|
|
106
|
+
|
|
107
|
+
module_function
|
|
108
|
+
|
|
109
|
+
# -- public API ------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
# Serialize a payload, shrinking it when its JSON exceeds `max_bytes` UTF-8
|
|
112
|
+
# bytes. Returns [json_text, payload, truncated]: the JSON to send or store,
|
|
113
|
+
# the effective payload it is the text of, and whether anything was cut.
|
|
114
|
+
#
|
|
115
|
+
# Pass the event `type` whenever you have it: for a known type the result
|
|
116
|
+
# is then a valid payload of that type within max_bytes (max_bytes >= 4096)
|
|
117
|
+
# whenever the input was a valid event. Idempotent: a result fed back in
|
|
118
|
+
# comes back unchanged (truncated false, the same object).
|
|
119
|
+
def serialize_payload(payload, max_bytes = MAX_PAYLOAD_BYTES, type = nil)
|
|
120
|
+
type = type.name if type.is_a?(Symbol)
|
|
121
|
+
type = nil unless type.is_a?(String)
|
|
122
|
+
plan = type.nil? ? nil : PLANS[type]
|
|
123
|
+
begin
|
|
124
|
+
json, spans = stringify_with_spans(payload, payload.is_a?(Hash) && payload.size <= MAX_TRIM_FIELDS)
|
|
125
|
+
rescue Unserializable
|
|
126
|
+
return unserializable_payload(payload, max_bytes, type, plan)
|
|
127
|
+
end
|
|
128
|
+
bytes = json.bytesize
|
|
129
|
+
return [json, payload, false] if bytes <= max_bytes
|
|
130
|
+
|
|
131
|
+
if payload.is_a?(Hash)
|
|
132
|
+
if payload.size <= MAX_TRIM_FIELDS
|
|
133
|
+
trimmed = truncate_fields(payload, max_bytes, bytes, json, spans)
|
|
134
|
+
return [trimmed[0], trimmed[1], true] if trimmed && valid_for?(type, trimmed[1])
|
|
135
|
+
end
|
|
136
|
+
if plan
|
|
137
|
+
skeleton = skeleton_payload(payload, type, plan, max_bytes, bytes, prefix(json, PREVIEW_CHARS))
|
|
138
|
+
return [skeleton[0], skeleton[1], true] if skeleton
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
marker = { "__graphmindTruncated" => true, "bytes" => bytes, "preview" => prefix(json, PREVIEW_CHARS) }
|
|
143
|
+
[stringify(marker), marker, true]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Whether `payload` is a valid payload of `type` (the zod schema in
|
|
147
|
+
# packages/schema/src/events.ts). Unknown types are valid (their payload is
|
|
148
|
+
# opaque), as in the TypeScript reference.
|
|
149
|
+
def valid_for?(type, payload)
|
|
150
|
+
type = type.name if type.is_a?(Symbol)
|
|
151
|
+
return true unless type.is_a?(String) && PLANS.key?(type)
|
|
152
|
+
|
|
153
|
+
Schema.valid?(type, payload)
|
|
154
|
+
rescue StandardError
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# JSON.stringify's text of a JSON-shaped value. Raises Unserializable.
|
|
159
|
+
def stringify(value)
|
|
160
|
+
writer = Writer.new
|
|
161
|
+
writer.value(value, 0)
|
|
162
|
+
writer.out
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# JSON text, or nil when the value has none.
|
|
166
|
+
def safe_stringify(value)
|
|
167
|
+
stringify(value)
|
|
168
|
+
rescue Unserializable
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Length of a string in UTF-16 code units (JavaScript's `.length`).
|
|
173
|
+
def utf16_length(text)
|
|
174
|
+
return text.bytesize if text.ascii_only?
|
|
175
|
+
|
|
176
|
+
raw = text.b
|
|
177
|
+
# One unit per character start (every byte that is not a continuation
|
|
178
|
+
# byte), plus one more for each 4-byte (astral) character. A kept lone
|
|
179
|
+
# surrogate (3 bytes, one start) counts 1, as in JavaScript.
|
|
180
|
+
raw.bytesize - raw.count("\x80-\xBF".b) + raw.count("\xF0-\xF7".b)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The first `count` UTF-16 code units of `text` (JavaScript's
|
|
184
|
+
# `text.slice(0, count)`); the text itself when it is not longer.
|
|
185
|
+
def prefix(text, count)
|
|
186
|
+
if text.ascii_only?
|
|
187
|
+
return text if text.bytesize <= count
|
|
188
|
+
|
|
189
|
+
return text.byteslice(0, count)
|
|
190
|
+
end
|
|
191
|
+
return text if utf16_length(text) <= count
|
|
192
|
+
|
|
193
|
+
window = text.byteslice(0, (count * 3) + 4).b
|
|
194
|
+
bytes = window.unpack("C*")
|
|
195
|
+
index = 0
|
|
196
|
+
units = 0
|
|
197
|
+
length = bytes.length
|
|
198
|
+
while units < count && index < length
|
|
199
|
+
lead = bytes[index]
|
|
200
|
+
step = if lead < 0x80 then 1
|
|
201
|
+
elsif lead < 0xE0 then 2
|
|
202
|
+
elsif lead < 0xF0 then 3
|
|
203
|
+
else 4
|
|
204
|
+
end
|
|
205
|
+
if step == 4
|
|
206
|
+
if units + 2 > count
|
|
207
|
+
# The cut falls between the halves of a surrogate pair: keep the
|
|
208
|
+
# lone high half, as JavaScript's slice does.
|
|
209
|
+
code = ((lead & 0x07) << 18) | (((bytes[index + 1] || 0x80) & 0x3F) << 12) |
|
|
210
|
+
(((bytes[index + 2] || 0x80) & 0x3F) << 6) | ((bytes[index + 3] || 0x80) & 0x3F)
|
|
211
|
+
high = 0xD800 + ((code - 0x10000) >> 10)
|
|
212
|
+
half = [0xE0 | (high >> 12), 0x80 | ((high >> 6) & 0x3F), 0x80 | (high & 0x3F)].pack("C*")
|
|
213
|
+
return (window.byteslice(0, index) + half).force_encoding(Encoding::UTF_8)
|
|
214
|
+
end
|
|
215
|
+
units += 2
|
|
216
|
+
else
|
|
217
|
+
units += 1
|
|
218
|
+
end
|
|
219
|
+
index += step
|
|
220
|
+
end
|
|
221
|
+
window.byteslice(0, index).force_encoding(Encoding::UTF_8)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# -- JSON writer -----------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
ESCAPES = (0x00..0x1F).to_h { |code| [code.chr, format("\\u%04x", code)] }
|
|
227
|
+
.merge("\b" => "\\b", "\t" => "\\t", "\n" => "\\n", "\f" => "\\f", "\r" => "\\r",
|
|
228
|
+
'"' => '\\"', "\\" => "\\\\").freeze
|
|
229
|
+
ESCAPE_RE = /["\\\x00-\x1F]/
|
|
230
|
+
SURROGATE_PAIR_RE = /\xED([\xA0-\xAF])([\x80-\xBF])\xED([\xB0-\xBF])([\x80-\xBF])/n
|
|
231
|
+
LONE_SURROGATE_RE = /(\xED[\xA0-\xBF][\x80-\xBF])/n
|
|
232
|
+
|
|
233
|
+
# Appends JSON text to one buffer. Top-level Hash field value spans are
|
|
234
|
+
# recorded (byte offsets) so the field trim never serializes a field twice.
|
|
235
|
+
class Writer
|
|
236
|
+
attr_reader :out
|
|
237
|
+
|
|
238
|
+
def initialize
|
|
239
|
+
@out = +""
|
|
240
|
+
@path = {}.compare_by_identity
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def value(value, depth, spans = nil)
|
|
244
|
+
case value
|
|
245
|
+
when String then string(value)
|
|
246
|
+
when Symbol then string(value.name)
|
|
247
|
+
when Integer then @out << Shrink.integer_text(value)
|
|
248
|
+
when Float then @out << Shrink.float_text(value)
|
|
249
|
+
when true then @out << "true"
|
|
250
|
+
when false then @out << "false"
|
|
251
|
+
when nil then @out << "null"
|
|
252
|
+
when Hash then object(value, depth, spans)
|
|
253
|
+
when Array then array(value, depth)
|
|
254
|
+
else raise Unserializable, "not a JSON value"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def string(text)
|
|
259
|
+
@out << Shrink.quote(text)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
def enter(container, depth)
|
|
265
|
+
raise Unserializable, "nested too deeply" if depth >= MAX_JSON_DEPTH
|
|
266
|
+
raise Unserializable, "cyclic" if @path.key?(container)
|
|
267
|
+
|
|
268
|
+
@path[container] = true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def object(hash, depth, spans)
|
|
272
|
+
enter(hash, depth)
|
|
273
|
+
@out << "{"
|
|
274
|
+
first = true
|
|
275
|
+
hash.each_pair do |key, item|
|
|
276
|
+
@out << "," unless first
|
|
277
|
+
first = false
|
|
278
|
+
string(Shrink.key_string(key))
|
|
279
|
+
@out << ":"
|
|
280
|
+
start = @out.bytesize
|
|
281
|
+
value(item, depth + 1)
|
|
282
|
+
spans << [start, @out.bytesize] if spans
|
|
283
|
+
end
|
|
284
|
+
@out << "}"
|
|
285
|
+
@path.delete(hash)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def array(list, depth)
|
|
289
|
+
enter(list, depth)
|
|
290
|
+
@out << "["
|
|
291
|
+
list.each_with_index do |item, i|
|
|
292
|
+
@out << "," unless i.zero?
|
|
293
|
+
value(item, depth + 1)
|
|
294
|
+
end
|
|
295
|
+
@out << "]"
|
|
296
|
+
@path.delete(list)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# [json, spans]: spans (for a Hash) lists each field value's byte range,
|
|
301
|
+
# in field order.
|
|
302
|
+
def stringify_with_spans(value, want_spans = true)
|
|
303
|
+
writer = Writer.new
|
|
304
|
+
spans = want_spans && value.is_a?(Hash) ? [] : nil
|
|
305
|
+
writer.value(value, 0, spans)
|
|
306
|
+
[writer.out, spans]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def key_string(key)
|
|
310
|
+
case key
|
|
311
|
+
when String then key
|
|
312
|
+
when Symbol then key.name
|
|
313
|
+
else
|
|
314
|
+
begin
|
|
315
|
+
text = key.to_s
|
|
316
|
+
rescue StandardError
|
|
317
|
+
raise Unserializable, "a key has no string form"
|
|
318
|
+
end
|
|
319
|
+
raise Unserializable, "a key has no string form" unless text.is_a?(String)
|
|
320
|
+
|
|
321
|
+
text
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# A JSON string literal, escaped exactly like JSON.stringify.
|
|
326
|
+
def quote(text)
|
|
327
|
+
text = utf8(text)
|
|
328
|
+
if text.valid_encoding?
|
|
329
|
+
body = text.match?(ESCAPE_RE) ? text.gsub(ESCAPE_RE, ESCAPES) : text
|
|
330
|
+
return "\"#{body}\""
|
|
331
|
+
end
|
|
332
|
+
# Kept surrogate halves (see the module comment). A high half directly
|
|
333
|
+
# followed by a low half is one character, as in JavaScript.
|
|
334
|
+
raw = text.b.gsub(SURROGATE_PAIR_RE) do
|
|
335
|
+
high = 0xD000 | ((Regexp.last_match(1).ord & 0x3F) << 6) | (Regexp.last_match(2).ord & 0x3F)
|
|
336
|
+
low = 0xD000 | ((Regexp.last_match(3).ord & 0x3F) << 6) | (Regexp.last_match(4).ord & 0x3F)
|
|
337
|
+
[0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00)].pack("U").b
|
|
338
|
+
end
|
|
339
|
+
body = +""
|
|
340
|
+
raw.split(LONE_SURROGATE_RE).each do |part|
|
|
341
|
+
if part.bytesize == 3 && LONE_SURROGATE_RE.match?(part)
|
|
342
|
+
bytes = part.unpack("C*")
|
|
343
|
+
code = ((bytes[0] & 0x0F) << 12) | ((bytes[1] & 0x3F) << 6) | (bytes[2] & 0x3F)
|
|
344
|
+
body << format("\\u%04x", code)
|
|
345
|
+
else
|
|
346
|
+
piece = part.force_encoding(Encoding::UTF_8)
|
|
347
|
+
raise Unserializable, "string is not UTF-8" unless piece.valid_encoding?
|
|
348
|
+
|
|
349
|
+
body << (piece.match?(ESCAPE_RE) ? piece.gsub(ESCAPE_RE, ESCAPES) : piece)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
"\"#{body}\""
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def utf8(text)
|
|
356
|
+
encoding = text.encoding
|
|
357
|
+
return text if encoding == Encoding::UTF_8
|
|
358
|
+
return text.dup.force_encoding(Encoding::UTF_8) if encoding == Encoding::BINARY || encoding == Encoding::US_ASCII
|
|
359
|
+
|
|
360
|
+
text.encode(Encoding::UTF_8)
|
|
361
|
+
rescue EncodingError
|
|
362
|
+
raise Unserializable, "string cannot be converted to UTF-8"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
TWO_53 = 2**53
|
|
366
|
+
|
|
367
|
+
# JSON.stringify of a Float. Ruby's Float#to_s is the shortest round-trip
|
|
368
|
+
# spelling, as JavaScript's is; the two differ only in form: Ruby writes
|
|
369
|
+
# "5.0" for 5 and an exponent outside 1e-4 <= |x| < 1e16. The positional
|
|
370
|
+
# form is taken as is (minus a ".0"); only an exponent form pays for the
|
|
371
|
+
# general conversion. (Measurably 10x faster on a 2,000,000-float array.)
|
|
372
|
+
def float_text(value)
|
|
373
|
+
return "null" unless value.finite?
|
|
374
|
+
return "0" if value.zero?
|
|
375
|
+
|
|
376
|
+
text = value.to_s
|
|
377
|
+
return LoopGuard.js_number(value) if text.include?("e")
|
|
378
|
+
|
|
379
|
+
text.end_with?(".0") ? text.byteslice(0, text.bytesize - 2) : text
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# JSON.stringify of an integer JavaScript would hold as a double.
|
|
383
|
+
def integer_text(value)
|
|
384
|
+
return value.to_s if value.abs <= TWO_53
|
|
385
|
+
|
|
386
|
+
double = integer_double(value)
|
|
387
|
+
double.finite? ? LoopGuard.js_number(double) : "null"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# The double JavaScript's JSON.parse makes of an integer's digits.
|
|
391
|
+
def integer_double(value)
|
|
392
|
+
return value.to_f if value.abs <= TWO_53
|
|
393
|
+
|
|
394
|
+
value.bit_length > 1100 ? Float::INFINITY : Float(value.to_s)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# -- tier 2: the field trim ------------------------------------------------
|
|
398
|
+
|
|
399
|
+
# Shrink one oversized field, PRESERVING ITS JSON TYPE (a required string
|
|
400
|
+
# stays a string, an object an object), so the event still validates.
|
|
401
|
+
def shrink_value(value, depth = 0, encoded = nil)
|
|
402
|
+
case value
|
|
403
|
+
when String, Symbol
|
|
404
|
+
text = value.is_a?(Symbol) ? value.name : value
|
|
405
|
+
return text if utf16_length(text) <= PREVIEW_CHARS
|
|
406
|
+
|
|
407
|
+
prefix(text, PREVIEW_CHARS) + TRUNCATION_SUFFIX
|
|
408
|
+
when Array then []
|
|
409
|
+
when Hash
|
|
410
|
+
if depth >= MAX_SHRINK_DEPTH
|
|
411
|
+
return { "__graphmindTruncated" => true, "bytes" => 0, "preview" => "[deeply nested]" }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
shrunk = {}
|
|
415
|
+
kept = 0
|
|
416
|
+
value.each_pair do |key, item|
|
|
417
|
+
break if kept == MAX_SHRINK_KEYS
|
|
418
|
+
|
|
419
|
+
shrunk[key_string(key)] = shrink_value(item, depth + 1)
|
|
420
|
+
kept += 1
|
|
421
|
+
end
|
|
422
|
+
dropped = value.size - kept
|
|
423
|
+
if depth.positive?
|
|
424
|
+
if dropped.positive?
|
|
425
|
+
shrunk["__graphmindTruncated"] = true
|
|
426
|
+
shrunk["keysDropped"] = dropped
|
|
427
|
+
end
|
|
428
|
+
return shrunk
|
|
429
|
+
end
|
|
430
|
+
text = encoded || safe_stringify(value)
|
|
431
|
+
shrunk["__graphmindTruncated"] = true
|
|
432
|
+
shrunk["bytes"] = text.nil? ? 0 : utf16_length(text)
|
|
433
|
+
shrunk["preview"] = text.nil? ? "[unserializable field]" : prefix(text, PREVIEW_CHARS)
|
|
434
|
+
shrunk["keysDropped"] = dropped if dropped.positive?
|
|
435
|
+
shrunk
|
|
436
|
+
else
|
|
437
|
+
value
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Trim the biggest fields of an oversized Hash payload until the rest is at
|
|
442
|
+
# most half the budget; nil when the result still does not fit.
|
|
443
|
+
def truncate_fields(payload, max_bytes, total_bytes, json, spans)
|
|
444
|
+
sizes = []
|
|
445
|
+
payload.each_pair.with_index do |(key, item), index|
|
|
446
|
+
span = spans && spans[index]
|
|
447
|
+
encoded = span ? json.byteslice(span[0], span[1] - span[0]) : safe_stringify(item)
|
|
448
|
+
size = encoded.nil? ? MAX_SAFE_INTEGER : utf16_length(encoded)
|
|
449
|
+
sizes << [key_string(key), size, encoded, item, index]
|
|
450
|
+
end
|
|
451
|
+
# Biggest first; equal sizes keep key order (a stable sort).
|
|
452
|
+
sizes.sort_by! { |entry| [-entry[1], entry[4]] }
|
|
453
|
+
|
|
454
|
+
trimmed = {}
|
|
455
|
+
payload.each_pair { |key, item| trimmed[key_string(key)] = item }
|
|
456
|
+
dropped = []
|
|
457
|
+
remaining = total_bytes
|
|
458
|
+
sizes.each do |key, size, encoded, item, _index|
|
|
459
|
+
break if remaining * 2 <= max_bytes # leave room for the marker itself
|
|
460
|
+
|
|
461
|
+
trimmed[key] = shrink_value(item, 0, encoded)
|
|
462
|
+
dropped << key
|
|
463
|
+
remaining -= size
|
|
464
|
+
end
|
|
465
|
+
return nil if dropped.empty?
|
|
466
|
+
|
|
467
|
+
trimmed["__graphmindTruncated"] = true
|
|
468
|
+
trimmed["bytes"] = total_bytes
|
|
469
|
+
trimmed["preview"] = prefix(json, PREVIEW_CHARS)
|
|
470
|
+
trimmed["fields"] = dropped
|
|
471
|
+
encoded = safe_stringify(trimmed)
|
|
472
|
+
return nil if encoded.nil? || encoded.bytesize > max_bytes
|
|
473
|
+
|
|
474
|
+
[encoded, trimmed]
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# -- unserializable payloads -----------------------------------------------
|
|
478
|
+
|
|
479
|
+
def unserializable_payload(payload, max_bytes, type, plan)
|
|
480
|
+
begin
|
|
481
|
+
if payload.is_a?(Hash)
|
|
482
|
+
trimmed = truncate_unserializable_fields(payload)
|
|
483
|
+
return [trimmed[0], trimmed[1], true] if trimmed && plan.nil?
|
|
484
|
+
|
|
485
|
+
if trimmed
|
|
486
|
+
bytes = trimmed[0].bytesize
|
|
487
|
+
return [trimmed[0], trimmed[1], true] if bytes <= max_bytes && valid_for?(type, trimmed[1])
|
|
488
|
+
|
|
489
|
+
if bytes > max_bytes && trimmed[1].size <= MAX_TRIM_FIELDS
|
|
490
|
+
fitted = truncate_fields(trimmed[1], max_bytes, bytes, trimmed[0], trimmed[2])
|
|
491
|
+
return [fitted[0], fitted[1], true] if fitted && valid_for?(type, fitted[1])
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
if plan
|
|
495
|
+
skeleton = skeleton_payload(payload, type, plan, max_bytes, 0, "[unserializable payload]")
|
|
496
|
+
return [skeleton[0], skeleton[1], true] if skeleton
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
rescue Unserializable
|
|
500
|
+
nil # a key with no JSON text: fall through to the whole-payload marker
|
|
501
|
+
end
|
|
502
|
+
marker = { "__graphmindTruncated" => true, "bytes" => 0, "preview" => "[unserializable payload]" }
|
|
503
|
+
[stringify(marker), marker, true]
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Keep every field that serializes; replace the others with a marker
|
|
507
|
+
# (arrays with []). [json, payload, spans], or nil when nothing was to blame.
|
|
508
|
+
def truncate_unserializable_fields(payload)
|
|
509
|
+
trimmed = {}
|
|
510
|
+
dropped = []
|
|
511
|
+
payload.each_pair do |key, item|
|
|
512
|
+
name = key_string(key)
|
|
513
|
+
unless safe_stringify(item).nil?
|
|
514
|
+
trimmed[name] = item
|
|
515
|
+
next
|
|
516
|
+
end
|
|
517
|
+
dropped << name
|
|
518
|
+
trimmed[name] =
|
|
519
|
+
if item.is_a?(Array)
|
|
520
|
+
[]
|
|
521
|
+
else
|
|
522
|
+
{ "__graphmindTruncated" => true, "bytes" => 0, "preview" => "[unserializable value]" }
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
return nil if dropped.empty?
|
|
526
|
+
|
|
527
|
+
trimmed["__graphmindTruncated"] = true
|
|
528
|
+
trimmed["fields"] = dropped
|
|
529
|
+
json, spans = stringify_with_spans(trimmed)
|
|
530
|
+
[json, trimmed, spans]
|
|
531
|
+
rescue Unserializable
|
|
532
|
+
nil
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# -- tier 3: the skeleton --------------------------------------------------
|
|
536
|
+
|
|
537
|
+
SKELETON_ATTEMPTS = [[SKELETON_CHARS, true], [SKELETON_CHARS, false], [SKELETON_MIN_CHARS, false]].freeze
|
|
538
|
+
|
|
539
|
+
def omitted_marker = { "__graphmindTruncated" => true, "bytes" => 0, "preview" => "[omitted]" }
|
|
540
|
+
|
|
541
|
+
# [found, value] for a planned key (a Symbol key counts as its name).
|
|
542
|
+
def lookup(hash, key)
|
|
543
|
+
return [true, hash[key]] if hash.key?(key)
|
|
544
|
+
|
|
545
|
+
symbol = key.to_sym
|
|
546
|
+
return [true, hash[symbol]] if hash.key?(symbol)
|
|
547
|
+
|
|
548
|
+
[false, nil]
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def scalar?(value)
|
|
552
|
+
value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer) || value.is_a?(Float) ||
|
|
553
|
+
true.equal?(value) || false.equal?(value)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# [out, verbatim_keys, kept]: the planned keys of `value`, hard-shrunk.
|
|
557
|
+
def keep_planned(value, plan, chars)
|
|
558
|
+
out = {}
|
|
559
|
+
verbatim = Set.new
|
|
560
|
+
kept = 0
|
|
561
|
+
plan.each do |key, sub|
|
|
562
|
+
found, child = lookup(value, key)
|
|
563
|
+
next unless found
|
|
564
|
+
next if sub == :optional && !scalar?(child)
|
|
565
|
+
|
|
566
|
+
shrunk, same = hard_shrink(child, sub == :optional ? nil : sub, chars)
|
|
567
|
+
out[key] = shrunk
|
|
568
|
+
kept += 1
|
|
569
|
+
verbatim << key if same
|
|
570
|
+
end
|
|
571
|
+
[out, verbatim, kept]
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# [value, verbatim] for one kept skeleton value.
|
|
575
|
+
def hard_shrink(value, plan, chars)
|
|
576
|
+
case value
|
|
577
|
+
when String, Symbol
|
|
578
|
+
text = value.is_a?(Symbol) ? value.name : value
|
|
579
|
+
return [text, true] if utf16_length(text) <= chars
|
|
580
|
+
|
|
581
|
+
[prefix(text, chars) + TRUNCATION_SUFFIX, false]
|
|
582
|
+
when Integer, Float, true, false, nil then [value, true]
|
|
583
|
+
when Array then [[], value.empty?]
|
|
584
|
+
else
|
|
585
|
+
if value.is_a?(Hash) && plan.is_a?(Hash)
|
|
586
|
+
out, verbatim, kept = keep_planned(value, plan, chars)
|
|
587
|
+
[out, verbatim.size == kept && value.size <= kept]
|
|
588
|
+
else
|
|
589
|
+
[omitted_marker, false]
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def skeleton_payload(payload, type, plan, max_bytes, bytes, preview)
|
|
595
|
+
SKELETON_ATTEMPTS.each do |chars, full|
|
|
596
|
+
out, verbatim, = keep_planned(payload, plan, chars)
|
|
597
|
+
out["__graphmindTruncated"] = true
|
|
598
|
+
out["bytes"] = bytes
|
|
599
|
+
out["preview"] = full ? preview : ""
|
|
600
|
+
if full
|
|
601
|
+
fields = []
|
|
602
|
+
units = 0
|
|
603
|
+
payload.each_key do |key|
|
|
604
|
+
name = key_string(key)
|
|
605
|
+
next if verbatim.include?(name)
|
|
606
|
+
|
|
607
|
+
fields << name
|
|
608
|
+
units += utf16_length(name)
|
|
609
|
+
# Every name costs at least its length in bytes: once the names
|
|
610
|
+
# alone exceed the budget this attempt cannot fit.
|
|
611
|
+
break if units > max_bytes
|
|
612
|
+
end
|
|
613
|
+
next if units > max_bytes
|
|
614
|
+
|
|
615
|
+
out["fields"] = fields
|
|
616
|
+
end
|
|
617
|
+
json = safe_stringify(out)
|
|
618
|
+
next if json.nil? || json.bytesize > max_bytes
|
|
619
|
+
next unless valid_for?(type, out)
|
|
620
|
+
|
|
621
|
+
return [json, out]
|
|
622
|
+
end
|
|
623
|
+
nil
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# The event payload schemas (packages/schema/src/events.ts and
|
|
627
|
+
# primitives.ts), as zod v4 applies them: loose objects (extra keys kept),
|
|
628
|
+
# an optional key may be absent but not null, numbers are finite,
|
|
629
|
+
# `.int()` means a safe integer.
|
|
630
|
+
module Schema
|
|
631
|
+
NODE_KINDS = %w[agent llm tool chain retriever server resource prompt custom].freeze
|
|
632
|
+
RUN_STATUSES = %w[ok error aborted].freeze
|
|
633
|
+
PAUSE_POINTS = %w[before after error].freeze
|
|
634
|
+
PAUSE_REASONS = %w[breakpoint error step loop].freeze
|
|
635
|
+
RESUME_ACTIONS = %w[continue retry inject abort].freeze
|
|
636
|
+
TOKEN_CHANNELS = %w[text reasoning tool-args].freeze
|
|
637
|
+
LOOP_KINDS = %w[repeat cycle error-repeat].freeze
|
|
638
|
+
SMART_RULES = %w[error-result truncated-tool-call].freeze
|
|
639
|
+
REFUSAL_CODES = %w[schema shape placeholder truncated disabled unsupported].freeze
|
|
640
|
+
|
|
641
|
+
module_function
|
|
642
|
+
|
|
643
|
+
def valid?(type, payload)
|
|
644
|
+
return false unless payload.is_a?(Hash)
|
|
645
|
+
|
|
646
|
+
case type
|
|
647
|
+
when "run.started"
|
|
648
|
+
required(payload, "app") { |v| str?(v) } &&
|
|
649
|
+
required(payload, "sdk") { |v| sdk?(v) } &&
|
|
650
|
+
optional(payload, "meta") { |v| v.is_a?(Hash) }
|
|
651
|
+
when "run.finished"
|
|
652
|
+
required(payload, "status") { |v| enum?(v, RUN_STATUSES) } &&
|
|
653
|
+
optional(payload, "error") { |v| error_info?(v) }
|
|
654
|
+
when "graph.hint"
|
|
655
|
+
required(payload, "nodes") { |v| v.is_a?(Array) && v.all? { |n| node_hint?(n) } }
|
|
656
|
+
when "node.started"
|
|
657
|
+
required(payload, "nodeId") { |v| str?(v) } &&
|
|
658
|
+
optional(payload, "parentId") { |v| str?(v) } &&
|
|
659
|
+
required(payload, "kind") { |v| enum?(v, NODE_KINDS) } &&
|
|
660
|
+
required(payload, "name") { |v| str?(v) } &&
|
|
661
|
+
required(payload, "instanceId") { |v| str?(v) } &&
|
|
662
|
+
optional(payload, "collapsed") { |v| true.equal?(v) || false.equal?(v) } &&
|
|
663
|
+
optional(payload, "redaction") { |v| redaction?(v) }
|
|
664
|
+
when "node.token"
|
|
665
|
+
required(payload, "nodeId") { |v| str?(v) } &&
|
|
666
|
+
required(payload, "deltas") { |v| v.is_a?(Array) && v.all? { |d| delta?(d) } } &&
|
|
667
|
+
optional(payload, "redaction") { |v| redaction?(v) }
|
|
668
|
+
when "node.finished"
|
|
669
|
+
required(payload, "nodeId") { |v| str?(v) } &&
|
|
670
|
+
optional(payload, "instanceId") { |v| str?(v) } &&
|
|
671
|
+
optional(payload, "usage") { |v| usage?(v) } &&
|
|
672
|
+
required(payload, "durationMs") { |v| nonnegative?(v) } &&
|
|
673
|
+
optional(payload, "heldMs") { |v| nonnegative?(v) } &&
|
|
674
|
+
required(payload, "status") { |v| enum?(v, RUN_STATUSES) } &&
|
|
675
|
+
optional(payload, "redaction") { |v| redaction?(v) }
|
|
676
|
+
when "node.error"
|
|
677
|
+
required(payload, "nodeId") { |v| str?(v) } &&
|
|
678
|
+
optional(payload, "instanceId") { |v| str?(v) } &&
|
|
679
|
+
required(payload, "error") { |v| error_info?(v) } &&
|
|
680
|
+
optional(payload, "heldMs") { |v| nonnegative?(v) }
|
|
681
|
+
when "exec.paused"
|
|
682
|
+
required(payload, "pauseId") { |v| str?(v) } &&
|
|
683
|
+
required(payload, "nodeId") { |v| str?(v) } &&
|
|
684
|
+
required(payload, "point") { |v| enum?(v, PAUSE_POINTS) } &&
|
|
685
|
+
optional(payload, "reason") { |v| enum?(v, PAUSE_REASONS) } &&
|
|
686
|
+
optional(payload, "loop") { |v| loop_info?(v) } &&
|
|
687
|
+
optional(payload, "smart") { |v| smart_info?(v) } &&
|
|
688
|
+
optional(payload, "editable") { |v| bool?(v) } &&
|
|
689
|
+
optional(payload, "instanceId") { |v| str?(v) }
|
|
690
|
+
when "exec.refused"
|
|
691
|
+
required(payload, "pauseId") { |v| str?(v) } &&
|
|
692
|
+
required(payload, "code") { |v| enum?(v, REFUSAL_CODES) } &&
|
|
693
|
+
optional(payload, "message") { |v| str?(v) } &&
|
|
694
|
+
optional(payload, "requestId") { |v| str?(v) }
|
|
695
|
+
when "exec.resumed"
|
|
696
|
+
required(payload, "pauseId") { |v| str?(v) } &&
|
|
697
|
+
required(payload, "action") { |v| enum?(v, RESUME_ACTIONS) } &&
|
|
698
|
+
# z.looseObject({after: z.unknown()}): zod v4 requires the key.
|
|
699
|
+
optional(payload, "edited") { |v| v.is_a?(Hash) && Shrink.lookup(v, "after")[0] } &&
|
|
700
|
+
optional(payload, "requestId") { |v| str?(v) } &&
|
|
701
|
+
optional(payload, "principal") { |v| str?(v) }
|
|
702
|
+
else
|
|
703
|
+
true
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def required(hash, key)
|
|
708
|
+
found, value = Shrink.lookup(hash, key)
|
|
709
|
+
found && yield(value)
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def optional(hash, key)
|
|
713
|
+
found, value = Shrink.lookup(hash, key)
|
|
714
|
+
!found || yield(value)
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def str?(value) = value.is_a?(String) || value.is_a?(Symbol)
|
|
718
|
+
|
|
719
|
+
def enum?(value, list) = str?(value) && list.include?(value.is_a?(Symbol) ? value.name : value)
|
|
720
|
+
|
|
721
|
+
def number?(value)
|
|
722
|
+
case value
|
|
723
|
+
when Float then value.finite?
|
|
724
|
+
when Integer then Shrink.integer_double(value).finite?
|
|
725
|
+
else false
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def nonnegative?(value) = number?(value) && value >= 0
|
|
730
|
+
|
|
731
|
+
def safe_integer?(value)
|
|
732
|
+
number?(value) && value.abs <= MAX_SAFE_INTEGER && (value.is_a?(Integer) || value == value.truncate)
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def sdk?(value) = value.is_a?(Hash) && required(value, "name") { |v| str?(v) } &&
|
|
736
|
+
required(value, "version") { |v| str?(v) }
|
|
737
|
+
|
|
738
|
+
def error_info?(value)
|
|
739
|
+
value.is_a?(Hash) && required(value, "name") { |v| str?(v) } &&
|
|
740
|
+
required(value, "message") { |v| str?(v) } && optional(value, "stack") { |v| str?(v) }
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def node_hint?(value)
|
|
744
|
+
value.is_a?(Hash) && required(value, "nodeId") { |v| str?(v) } &&
|
|
745
|
+
required(value, "kind") { |v| enum?(v, NODE_KINDS) } && required(value, "name") { |v| str?(v) } &&
|
|
746
|
+
optional(value, "parentId") { |v| str?(v) }
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def delta?(value)
|
|
750
|
+
value.is_a?(Hash) && required(value, "t") { |v| enum?(v, TOKEN_CHANNELS) } &&
|
|
751
|
+
required(value, "v") { |v| str?(v) }
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def usage?(value)
|
|
755
|
+
value.is_a?(Hash) && required(value, "inputTokens") { |v| count?(v) } &&
|
|
756
|
+
required(value, "outputTokens") { |v| count?(v) } &&
|
|
757
|
+
optional(value, "inclusive") { |v| bool?(v) } &&
|
|
758
|
+
optional(value, "cacheReadTokens") { |v| count?(v) } &&
|
|
759
|
+
optional(value, "cacheWriteTokens") { |v| count?(v) } &&
|
|
760
|
+
optional(value, "reasoningTokens") { |v| count?(v) }
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def bool?(value) = true.equal?(value) || false.equal?(value)
|
|
764
|
+
|
|
765
|
+
def smart_info?(value)
|
|
766
|
+
value.is_a?(Hash) && required(value, "rule") { |v| enum?(v, SMART_RULES) } &&
|
|
767
|
+
optional(value, "detail") { |v| str?(v) }
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def redaction?(value)
|
|
771
|
+
value.is_a?(Hash) && required(value, "count") { |v| count?(v) } &&
|
|
772
|
+
required(value, "keys") { |v| v.is_a?(Array) && v.all? { |k| str?(k) } }
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def loop_info?(value)
|
|
776
|
+
value.is_a?(Hash) && required(value, "repeats") { |v| count?(v) } &&
|
|
777
|
+
required(value, "firstSeq") { |v| count?(v) } && required(value, "lastSeq") { |v| count?(v) } &&
|
|
778
|
+
required(value, "fingerprint") { |v| str?(v) } &&
|
|
779
|
+
optional(value, "kind") { |v| enum?(v, LOOP_KINDS) } &&
|
|
780
|
+
optional(value, "period") { |v| count?(v) && v.positive? } &&
|
|
781
|
+
optional(value, "laps") { |v| count?(v) && v.positive? }
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def count?(value) = safe_integer?(value) && value >= 0
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
end
|