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.
@@ -0,0 +1,1180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "clock"
4
+ require_relative "env"
5
+ require_relative "errors"
6
+ require_relative "gate_engine"
7
+ require_relative "held_ledger"
8
+ require_relative "integrations/support"
9
+ require_relative "ids"
10
+ require_relative "loop_guard"
11
+ require_relative "protocol"
12
+ require_relative "redaction"
13
+ require_relative "ring_buffer"
14
+ require_relative "runtime"
15
+ require_relative "safe"
16
+ require_relative "shrink"
17
+ require_relative "token_batcher"
18
+ require_relative "transport"
19
+ require_relative "version"
20
+
21
+ module Graphmind
22
+ # One top-level agent invocation.
23
+ #
24
+ # `aborted?` flips when the debugger resolves a gate with `abort`. Anything
25
+ # GraphMind does not wrap (a raw HTTP call, a long loop) can poll it so an
26
+ # abort reaches code the gem cannot see.
27
+ class RunContext
28
+ attr_reader :run_id, :name
29
+
30
+ def initialize(run_id, name)
31
+ @run_id = run_id
32
+ @name = name
33
+ @mutex = Mutex.new
34
+ @aborted = false
35
+ @reason = nil
36
+ end
37
+
38
+ def aborted? = @mutex.synchronize { @aborted }
39
+ def reason = @mutex.synchronize { @reason }
40
+
41
+ def abort!(reason = nil)
42
+ @mutex.synchronize do
43
+ @aborted = true
44
+ @reason ||= reason || Graphmind::AbortError.new
45
+ end
46
+ nil
47
+ end
48
+
49
+ def to_s = "#<Graphmind::RunContext #{@name} #{@run_id}#{aborted? ? ' aborted' : ''}>"
50
+ alias inspect to_s
51
+ end
52
+
53
+ # `dropped` counts frames the replay buffer EVICTED; `lost` counts events
54
+ # that never reached the debugger at all — evicted while dark, or refused
55
+ # because one frame was bigger than the whole buffer. Parity with the
56
+ # TypeScript client's SessionStats, where `lost` is "the number that
57
+ # matters": without it a refused oversize frame was warned about in the log
58
+ # but invisible to the host program (`dropped` stayed 0).
59
+ SessionStats = Struct.new(:enabled, :attached, :buffered, :dropped, :lost, :held_gates, :seq,
60
+ keyword_init: true) do
61
+ def to_h
62
+ {
63
+ "enabled" => enabled, "attached" => attached, "buffered" => buffered,
64
+ "dropped" => dropped, "lost" => lost, "heldGates" => held_gates, "seq" => seq
65
+ }
66
+ end
67
+ end
68
+
69
+ # The GraphMind session: the one object every integration talks to.
70
+ #
71
+ # Port of packages/client/src/session.ts.
72
+ #
73
+ # Guarantees:
74
+ # * NEVER raises into the host app. Internal failures no-op with a
75
+ # rate-limited warning. (Errors raised by the host's own code inside a
76
+ # run propagate untouched — they are the host's errors.)
77
+ # * Zero-cost when detached: #gate returns the shared CONTINUE after three
78
+ # reads and no allocation.
79
+ # * Fail-open: disconnect, dispose and interpreter exit auto-continue every
80
+ # held gate.
81
+ # * Kill switches: GRAPHMIND_DISABLED=1 always disables; a
82
+ # production-looking environment disables unless GRAPHMIND=1. A disabled
83
+ # session never touches the network.
84
+ class Session
85
+ DEFAULT_CONNECT_TIMEOUT = 0.3
86
+ DEFAULT_HANDSHAKE_TIMEOUT = 1.0
87
+ DEFAULT_RETRY_INTERVAL = 10.0
88
+ DEFAULT_BUFFER_SIZE = 2000
89
+ DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024
90
+ DEFAULT_READY_TIMEOUT = 2.0
91
+ DEFAULT_TOKEN_INTERVAL = 0.034
92
+
93
+ # How often a blocked gate re-checks the fail-open conditions. The primary
94
+ # release path is the disconnect callback (sub-millisecond); this poll only
95
+ # exists so a blocked thread can never outlive the debugger even if a
96
+ # callback is somehow missed.
97
+ GATE_POLL = 0.25
98
+
99
+ # exec.paused.reason of a hold no built-in breakpoint raised (0.6.0).
100
+ MATCHED_REASONS = %w[breakpoint step error].freeze
101
+
102
+ # Fiber-local, which under Puma/Sidekiq means thread-local: each request or
103
+ # job runs on its own thread's root fiber. See README "Threads".
104
+ RUN_KEY = :graphmind_run_context
105
+
106
+ attr_reader :app_name, :sdk, :meta, :warner
107
+
108
+ def initialize(url: nil, app_name: "ruby", sdk: nil, meta: nil, enabled: nil,
109
+ connect_timeout: DEFAULT_CONNECT_TIMEOUT,
110
+ handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT,
111
+ retry_interval: DEFAULT_RETRY_INTERVAL,
112
+ buffer_size: DEFAULT_BUFFER_SIZE,
113
+ max_buffer_bytes: DEFAULT_MAX_BUFFER_BYTES,
114
+ pause_timeout: nil,
115
+ token_interval: DEFAULT_TOKEN_INTERVAL,
116
+ env: nil, logger: nil, warn_interval: 60.0,
117
+ clock: nil, loop_guard: nil,
118
+ hide_inputs: nil, hide_outputs: nil, hide_tool_args: nil, hide_tool_results: nil)
119
+ @enabled = Env.resolve_enabled(enabled, env)
120
+ @app_name = app_name.to_s
121
+ @sdk = sdk || { "name" => "ruby", "version" => Graphmind::VERSION }
122
+ @meta = meta
123
+
124
+ @warner = Warner.new(warn_interval, logger)
125
+ @mutex = Mutex.new
126
+ @buffer = RingBuffer.new(buffer_size, max_buffer_bytes)
127
+ @seq = 0
128
+ # Identity handed out by the debugger in hello.ack; see build_hello.
129
+ @session_token = nil
130
+ @started = false
131
+ @disposed = false
132
+ @attached_mirror = false
133
+ @implicit_run = nil
134
+ @lost = 0
135
+ # What the attached debugger implements (hello.ack.hubCapabilities,
136
+ # 0.6.0+); nil for a 0.5 debugger, which sends none, and while detached.
137
+ # This gem announces no `edit-input`, so it only decides whether a
138
+ # refused inject is also worth a line in the app's log.
139
+ @hub_capabilities = nil
140
+
141
+ @ready_mutex = Mutex.new
142
+ @ready_cv = ConditionVariable.new
143
+
144
+ # Pins gate holds to node instances so node.finished can carry `heldMs`.
145
+ # `clock` is a monotonic millisecond clock, injectable for tests.
146
+ @ledger = HeldLedger.new(clock: clock)
147
+ env_source = env.nil? ? ENV : env
148
+ # Coarse redaction (W7 port): the GRAPHMIND_HIDE_* kill switches, applied
149
+ # in emit_internal before the ring buffer. Either the option or the
150
+ # environment turning a switch on turns it on (the env is a floor).
151
+ @redactor = Redaction::Redactor.new(
152
+ Redaction.resolve({ hide_inputs: hide_inputs, hide_outputs: hide_outputs,
153
+ hide_tool_args: hide_tool_args, hide_tool_results: hide_tool_results },
154
+ env_source),
155
+ # Fail-closed reports (a failed form sent, or an event dropped), one
156
+ # line per key per interval — never the payload, never the error text.
157
+ warn: ->(key, message) { @warner.warn(key, message) }
158
+ )
159
+ # Loop hold (W5 port; rule v3): identical tool calls made back-to-back,
160
+ # recorded at node.started and consulted at the before-gate. `loop_guard:` is false
161
+ # or a Hash (threshold / mode / ignore_keys / allow_nodes / kinds);
162
+ # GRAPHMIND_LOOP_THRESHOLD / GRAPHMIND_ON_LOOP / GRAPHMIND_LOOP_ALLOW fill
163
+ # the rest.
164
+ @loop_guard = LoopGuard::Guard.new(LoopGuard.resolve(loop_guard, env_source))
165
+
166
+ @engine = GateEngine.new(
167
+ on_paused: method(:on_paused),
168
+ on_resumed: method(:on_resumed),
169
+ new_pause_id: -> { Ids.next_id("pause") },
170
+ pause_timeout: pause_timeout
171
+ )
172
+ @batcher = TokenBatcher.new(
173
+ ->(node_id, deltas) { emit("node.token", { "nodeId" => node_id, "deltas" => deltas }) },
174
+ token_interval
175
+ )
176
+ @transport = Transport.new(
177
+ url: Env.resolve_url(url, env),
178
+ hooks: Transport::Hooks.new(
179
+ build_hello: method(:build_hello),
180
+ on_attached: method(:handle_attached),
181
+ on_detached: method(:handle_detached),
182
+ on_control: method(:handle_control)
183
+ ),
184
+ warner: @warner,
185
+ connect_timeout: connect_timeout,
186
+ handshake_timeout: handshake_timeout,
187
+ retry_interval: retry_interval
188
+ )
189
+ @shutdown_hook = -> { fail_open_now }
190
+ Runtime.register(@shutdown_hook)
191
+ end
192
+
193
+ # -- state ---------------------------------------------------------------
194
+
195
+ def enabled? = @enabled
196
+ def attached? = @transport.attached?
197
+ def disposed? = @disposed
198
+ def url = @transport.url
199
+
200
+ def stats
201
+ SessionStats.new(
202
+ enabled: @enabled,
203
+ attached: attached?,
204
+ buffered: @mutex.synchronize { @buffer.size },
205
+ dropped: @mutex.synchronize { @buffer.dropped },
206
+ lost: @mutex.synchronize { @lost },
207
+ held_gates: @engine.held_count,
208
+ seq: @mutex.synchronize { @seq }
209
+ )
210
+ end
211
+
212
+ # -- attach --------------------------------------------------------------
213
+
214
+ # Block until the handshake completes (breakpoints armed).
215
+ #
216
+ # Returns false on timeout or when GraphMind is disabled. Never raises:
217
+ # false means "carry on detached", it is not an error.
218
+ def ready(timeout = DEFAULT_READY_TIMEOUT)
219
+ return false unless active?
220
+
221
+ ensure_started
222
+ # Re-arm: after a failure or a disconnect, do not sit out the retry
223
+ # interval — connect now.
224
+ @transport.kick
225
+ return true if @transport.attached?
226
+
227
+ deadline = monotonic + timeout
228
+ @ready_mutex.synchronize do
229
+ loop do
230
+ break if @transport.attached? || @disposed
231
+
232
+ remaining = deadline - monotonic
233
+ break if remaining <= 0
234
+
235
+ @ready_cv.wait(@ready_mutex, remaining)
236
+ end
237
+ end
238
+ @transport.attached? && active?
239
+ rescue StandardError => e
240
+ @warner.warn("ready", "internal error in ready(); resolving detached", e)
241
+ false
242
+ end
243
+
244
+ # -- runs ----------------------------------------------------------------
245
+
246
+ def current_run = Thread.current[RUN_KEY]
247
+
248
+ # Run boundary. Emits run.started / run.finished plus an `agent:<name>`
249
+ # node, and carries the RunContext the debugger's `abort` action targets.
250
+ # The block's value is returned; the block's errors propagate untouched.
251
+ def run(name, meta: nil)
252
+ ctx = RunContext.new(Ids.new_id("run"), name.to_s)
253
+ previous = Thread.current[RUN_KEY]
254
+ Thread.current[RUN_KEY] = ctx
255
+ node_id = Ids.agent_node_id(name)
256
+ started = Clock.now_ms
257
+ begin_run(ctx, name, meta, node_id)
258
+ error = nil
259
+ begin
260
+ yield ctx
261
+ rescue Exception => e # rubocop:disable Lint/RescueException
262
+ error = e
263
+ raise
264
+ ensure
265
+ Thread.current[RUN_KEY] = previous
266
+ end_run(ctx, node_id, started, error)
267
+ end
268
+ end
269
+
270
+ # Adopt an existing run context on this thread (for work you hand to a
271
+ # thread pool yourself — see README "Threads").
272
+ def with_run_context(ctx)
273
+ previous = Thread.current[RUN_KEY]
274
+ Thread.current[RUN_KEY] = ctx
275
+ yield
276
+ ensure
277
+ Thread.current[RUN_KEY] = previous
278
+ end
279
+
280
+ # Forget any run context bound to this thread. Rails' executor calls this
281
+ # after every request so a run cannot leak into the next one served by the
282
+ # same Puma thread.
283
+ def clear_run_context
284
+ Thread.current[RUN_KEY] = nil
285
+ end
286
+
287
+ # -- events --------------------------------------------------------------
288
+
289
+ def emit(type, payload)
290
+ emit_event(type, payload, NO_RAW_INPUT)
291
+ end
292
+
293
+ # Loop hold (W5 port): the redaction switches and the loop guard as this
294
+ # session resolved them (diagnostics / tests).
295
+ def redaction_switches = @redactor.switches
296
+ def loop_guard_config = @loop_guard.config
297
+
298
+ # `raw_input` is what the caller handed to #start_node before `sanitize`
299
+ # (which truncates long arrays and hashes): the loop fingerprint must see
300
+ # every argument, or two calls that differ past the 200th element would
301
+ # look identical.
302
+ def emit_event(type, payload, raw_input)
303
+ return unless active?
304
+
305
+ ensure_started
306
+ emit_internal(type, payload, resolve_run_id, raw_input)
307
+ nil
308
+ rescue StandardError => e
309
+ @warner.warn("emit", "internal error in emit(); GraphMind degrading to a no-op", e)
310
+ nil
311
+ end
312
+ private :emit_event
313
+
314
+ def start_node(node_id:, kind:, name:, instance_id:, parent_id: nil, input: nil, extra: nil)
315
+ payload = {
316
+ "nodeId" => node_id, "kind" => kind, "name" => name, "instanceId" => instance_id
317
+ }
318
+ payload["parentId"] = parent_id unless parent_id.nil?
319
+ payload["input"] = sanitize(input) unless input.nil?
320
+ payload.merge!(extra) if extra
321
+ raw = extra.is_a?(Hash) && (extra.key?("input") || extra.key?(:input)) ? NO_RAW_INPUT : input
322
+ emit_event("node.started", payload, raw)
323
+ end
324
+
325
+ def finish_node(node_id:, instance_id:, duration_ms:, status: "ok", output: nil, usage: nil,
326
+ extra: nil)
327
+ @batcher.flush_node(node_id)
328
+ payload = {
329
+ "nodeId" => node_id,
330
+ "instanceId" => instance_id,
331
+ "durationMs" => Clock.normalize_duration_ms(duration_ms),
332
+ "status" => status
333
+ }
334
+ payload["output"] = sanitize(output) unless output.nil?
335
+ payload["usage"] = usage unless usage.nil?
336
+ payload.merge!(extra) if extra
337
+ emit("node.finished", payload)
338
+ end
339
+
340
+ def error_node(node_id, instance_id, error)
341
+ emit("node.error", {
342
+ "nodeId" => node_id,
343
+ "instanceId" => instance_id,
344
+ "error" => Errors.to_error_info(error)
345
+ })
346
+ end
347
+
348
+ def graph_hint(nodes)
349
+ list = Array(nodes).select { |n| n.is_a?(Hash) }
350
+ emit("graph.hint", { "nodes" => list }) unless list.empty?
351
+ end
352
+
353
+ # Queue one streamed delta (batched into node.token).
354
+ def push_token(node_id, channel, value)
355
+ return if !active? || value.nil? || value.to_s.empty?
356
+
357
+ channel = "text" unless Protocol::TOKEN_CHANNELS.include?(channel)
358
+ @batcher.push(node_id, channel, value.to_s)
359
+ nil
360
+ rescue StandardError
361
+ nil
362
+ end
363
+
364
+ def flush
365
+ @batcher.flush_all
366
+ rescue StandardError
367
+ nil
368
+ end
369
+
370
+ # -- gates ---------------------------------------------------------------
371
+
372
+ # Hold the CALLING THREAD until the debugger resumes.
373
+ #
374
+ # Fast path (disabled, detached, or attached with nothing matching) returns
375
+ # the shared CONTINUE without allocating.
376
+ def gate(point, node)
377
+ return CONTINUE unless active?
378
+
379
+ ensure_started
380
+ return CONTINUE unless @transport.attached?
381
+
382
+ loop_info = consult_loop(point, node)
383
+ return CONTINUE if loop_info.nil? && !@engine.should_pause?(point, node)
384
+
385
+ hold = @engine.hold(point, node, resolve_run_id, loop_info || matched_reason(point, node))
386
+ settled = false
387
+ begin
388
+ decision = nil
389
+ loop do
390
+ decision = hold.wait(GATE_POLL)
391
+ break unless decision.nil?
392
+
393
+ if @disposed || !@transport.attached?
394
+ # Belt and braces: the disconnect callback normally releases held
395
+ # gates within a millisecond of the socket dying.
396
+ settled = true
397
+ @engine.discard(hold.pause_id)
398
+ return CONTINUE
399
+ end
400
+ end
401
+ settled = true
402
+ apply_decision(decision)
403
+ ensure
404
+ # Interrupt (Ctrl-C) is not a StandardError and unwinds straight past
405
+ # the rescue below; the gate must not stay registered.
406
+ @engine.discard(hold.pause_id) unless settled
407
+ end
408
+ rescue Graphmind::AbortError
409
+ raise
410
+ rescue StandardError => e
411
+ @warner.warn("gate", "internal gate error; continuing", e)
412
+ CONTINUE
413
+ end
414
+
415
+ # The exception to raise after an `abort` decision.
416
+ def abort_error(ctx = nil)
417
+ ctx ||= current_run
418
+ (ctx && ctx.reason) || Graphmind::AbortError.new
419
+ end
420
+
421
+ # -- lifecycle -----------------------------------------------------------
422
+
423
+ def dispose
424
+ return if @disposed
425
+
426
+ @disposed = true
427
+ Runtime.unregister(@shutdown_hook)
428
+ swallow { @batcher.dispose }
429
+ swallow do
430
+ @engine.release_all
431
+ @engine.disarm
432
+ end
433
+ swallow do
434
+ implicit = @mutex.synchronize { @implicit_run }
435
+ emit_internal("run.finished", { "status" => "ok" }, implicit.run_id) if implicit && @enabled
436
+ end
437
+ # Give the writer a moment to drain the last frames; bounded, so dispose
438
+ # is never a hang.
439
+ drain(0.25)
440
+ swallow { @transport.dispose }
441
+ settle_ready
442
+ nil
443
+ end
444
+
445
+ private
446
+
447
+ def active? = @enabled && !@disposed
448
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
449
+
450
+ def swallow
451
+ yield
452
+ rescue StandardError
453
+ nil
454
+ end
455
+
456
+ def drain(timeout)
457
+ return unless @attached_mirror
458
+
459
+ deadline = monotonic + timeout
460
+ sleep(0.005) while @transport.pending.positive? && monotonic < deadline
461
+ rescue StandardError
462
+ nil
463
+ end
464
+
465
+ def fail_open_now
466
+ @engine.release_all
467
+ rescue StandardError
468
+ nil
469
+ end
470
+
471
+ def ensure_started
472
+ return if @started
473
+
474
+ start = @mutex.synchronize do
475
+ if @started
476
+ false
477
+ else
478
+ @started = true
479
+ end
480
+ end
481
+ @transport.start if start
482
+ end
483
+
484
+ def resolve_run_id
485
+ ctx = Thread.current[RUN_KEY]
486
+ return ctx.run_id if ctx
487
+
488
+ implicit = @mutex.synchronize { @implicit_run }
489
+ return implicit.run_id if implicit
490
+
491
+ created = nil
492
+ run_id = @mutex.synchronize do
493
+ if @implicit_run.nil?
494
+ @implicit_run = RunContext.new(Ids.new_id("run"), "implicit")
495
+ created = @implicit_run
496
+ end
497
+ @implicit_run.run_id
498
+ end
499
+ if created
500
+ meta = { "name" => "implicit", "implicit" => true }
501
+ meta.merge!(stringify(@meta)) if @meta
502
+ emit_internal("run.started",
503
+ { "app" => @app_name, "sdk" => @sdk, "meta" => meta },
504
+ created.run_id)
505
+ end
506
+ run_id
507
+ end
508
+
509
+ # Serialize the payload (held to the payload budget), then take the seq,
510
+ # frame, buffer and hand off under one lock.
511
+ #
512
+ # The enqueue stays inside the lock deliberately: two host threads emitting
513
+ # at once must reach the socket in the same order they took their sequence
514
+ # numbers, or the viewer sees `seq` going backwards. `Transport#enqueue` is
515
+ # a non-blocking push onto a bounded queue, so the critical section stays
516
+ # short. The payload's JSON — and, for an oversized one, its parse and
517
+ # shrink — happens BEFORE the lock: a 17 MB tool result must not stall every
518
+ # other thread's emit while it is measured. The frame is the envelope's
519
+ # small head spliced with that text, byte-identical to JSON.generate of the
520
+ # whole envelope (`payload` is its last key and the generator writes no
521
+ # whitespace).
522
+ def emit_internal(type, payload, run_id, raw_input = NO_RAW_INPUT, node_kind: nil)
523
+ original = payload
524
+ # Loop hold (W5 port): fingerprint a watched node's input exactly as the
525
+ # caller handed it over — before redaction can replace it — and outside
526
+ # the lock (it walks the whole input).
527
+ loop_call = type == "node.started" ? loop_call_for(payload, raw_input) : nil
528
+ # Coarse redaction (W7 port) runs before the ring buffer and before any
529
+ # other bookkeeping reads the payload: nothing past this line may see a
530
+ # hidden input/output. A no-op when every switch is off. It fails closed:
531
+ # DROP means the payload could not be redacted nor replaced by a valid
532
+ # failed form, so the event is not emitted (the redactor already warned;
533
+ # no seq is taken, so there is no hole).
534
+ # `node_kind`: the paused node's kind, for exec.resumed / exec.refused.
535
+ payload = @redactor.apply(type, payload, run_id, node_kind)
536
+ if payload.equal?(Redaction::DROP)
537
+ # The call happened but nothing of it reached the wire: it is not
538
+ # provably the call right before the next one (loop rule 3), and a
539
+ # hold's lastSeq must never point at an event that was never sent.
540
+ @mutex.synchronize { record_loop(run_id, loop_call, nil, LoopGuard::UNREADABLE) } if loop_call
541
+ release_tool_schemas_of(original, raw_input) if type == "node.started"
542
+ return nil
543
+ end
544
+ payload = with_held_time(type, payload, run_id)
545
+ lost_now = 0
546
+ record = nil
547
+ oversize = nil
548
+ frame = nil
549
+ begin
550
+ # Payload budget, AFTER redaction and held time, BEFORE the ring
551
+ # buffer: what is buffered and sent is exactly what the server stores.
552
+ payload_json, whole = payload_json_within_budget(type, payload)
553
+ rescue StandardError
554
+ # Never sent (a NaN, a to_json that raises...): it takes no seq, and
555
+ # like a dropped start it must not count; it clears the streak (loop
556
+ # rule 3). emit rescues.
557
+ @mutex.synchronize { record_loop(run_id, loop_call, nil, LoopGuard::UNREADABLE) } if loop_call
558
+ release_tool_schemas_of(original, raw_input) if type == "node.started"
559
+ raise
560
+ end
561
+ # Tool definitions go out once per run (Support.capture_tools): ones this
562
+ # shrink emptied (`required: []`) must be sent again next step.
563
+ release_tool_schemas_of(original, raw_input) if !whole && type == "node.started"
564
+ @mutex.synchronize do
565
+ seq = @seq
566
+ @seq += 1
567
+ begin
568
+ frame = frame_with_payload(Protocol.create_envelope(type, nil, seq, run_id), payload_json)
569
+ rescue StandardError
570
+ # The head itself cannot be written (a run id that is not UTF-8):
571
+ # give the seq back — nothing else can have taken one under the lock.
572
+ @seq = seq
573
+ record_loop(run_id, loop_call, nil, LoopGuard::UNREADABLE) if loop_call
574
+ raise
575
+ end
576
+ # Recorded only once the frame exists, so a hold's lastSeq always
577
+ # names an event that was serialised.
578
+ record = record_loop(run_id, loop_call, seq) if loop_call
579
+ dropped_before = @buffer.dropped
580
+ if !@buffer.push(frame)
581
+ # Larger than the whole replay buffer: refused rather than evicting
582
+ # every older frame for it. Attached, it is sent live (not
583
+ # replayable, but not lost); dark, it is lost — counted like any
584
+ # other loss, with a warning of its own.
585
+ if @attached_mirror && @transport.enqueue(frame)
586
+ oversize = :sent
587
+ else
588
+ @lost += 1
589
+ oversize = :lost
590
+ end
591
+ elsif @attached_mirror
592
+ @transport.enqueue(frame)
593
+ else
594
+ # An evicted frame that was already delivered is merely forgotten;
595
+ # one evicted while we were dark is a hole in the recorded run.
596
+ lost_now = @buffer.dropped - dropped_before
597
+ @lost += lost_now
598
+ end
599
+ end
600
+ warn_unbuffered(oversize, frame.bytesize) if oversize
601
+ warn_loss if lost_now.positive?
602
+ warn_loop(run_id, loop_call, record) if record&.at_threshold
603
+ nil
604
+ end
605
+
606
+ # -- payload budget (port of session.ts serializeWithinBudget) -------------
607
+ #
608
+ # The payload's JSON text, held to the protocol's payload budget
609
+ # (Shrink::MAX_PAYLOAD_BYTES, 512 KB of UTF-8 as JavaScript writes it).
610
+ #
611
+ # The server always shrank a larger payload before storing it. Doing it
612
+ # only there meant a 17 MB payload was framed whole: bigger than the 8 MiB
613
+ # replay buffer (evicting every older frame) and than the server's 16 MiB
614
+ # frame cap, so the event vanished and its node hung "running". Applying
615
+ # the SAME algorithm here, with the event type, makes the event degrade
616
+ # exactly as the server would have stored it (a valid event stays valid);
617
+ # the server's own pass is then a no-op.
618
+ #
619
+ # The shrink runs on the payload parsed back out of the generated JSON —
620
+ # what the server parses — never on the live object: json writes a Time, a
621
+ # Struct, anything with #to_json or #to_s as text, and the shrink must see
622
+ # that text, not the object.
623
+ #
624
+ # Under the budget the text is JSON.generate's, unchanged. The pre-check is
625
+ # exact: token by token, JavaScript's spelling is never longer than json's
626
+ # except for a float json writes with an exponent, and then at most 21/5
627
+ # times as long ("1e+20" -> 21 digits; measured over 23,196 doubles with
628
+ # json 2.7, 2.21 and 3.0). So only a text over MAX * 5/21 bytes that is
629
+ # over the budget or has an exponent pays for the parse and the measure.
630
+ #
631
+ # A payload json cannot generate at all (a cycle, a string that is not
632
+ # UTF-8, a #to_json that raises) is degraded field by field when the shrink
633
+ # can blame a field, and sent with a marker in its place; when it cannot
634
+ # (a NaN, which JavaScript would write as null) the error propagates and
635
+ # the event is dropped as before. Warnings are one per event type per
636
+ # interval and never quote content.
637
+ EXPONENT_RE = /\de[+-]/
638
+ private_constant :EXPONENT_RE
639
+
640
+ # Returns [json, whole]: whole is false when the payload was shrunk or
641
+ # degraded (what capture_tools' once-per-run tool definitions must know).
642
+ def payload_json_within_budget(type, payload)
643
+ type_name = type.is_a?(Symbol) ? type.name : type
644
+ degraded = nil
645
+ begin
646
+ json = JSON.generate(payload)
647
+ rescue StandardError => e
648
+ degraded_json, degraded, truncated = Shrink.serialize_payload(payload, Float::INFINITY, type_name)
649
+ raise e unless truncated
650
+
651
+ json = degraded_json
652
+ if Shrink.valid_for?(type_name, degraded)
653
+ @warner.warn("payload-unserializable:#{type_name}",
654
+ "a #{type_name} event had a value that could not be serialized to JSON; it was sent " \
655
+ "with that value replaced by a marker")
656
+ else
657
+ @warner.warn("payload-invalid:#{type_name}",
658
+ "a #{type_name} event had a value that could not be serialized to JSON, and it could " \
659
+ "not be degraded to a valid event; the debugger will drop it")
660
+ end
661
+ # Fall through: the fields that DID serialize may still be over budget
662
+ # (a cycle next to a 17 MB string), exactly as the server would see it.
663
+ end
664
+ bytes = json.bytesize
665
+ return [json, degraded.nil?] if bytes * 21 <= Shrink::MAX_PAYLOAD_BYTES * 5 ||
666
+ (bytes <= Shrink::MAX_PAYLOAD_BYTES && !json.match?(EXPONENT_RE))
667
+
668
+ wire =
669
+ begin
670
+ # allow_duplicate_key: {a: 1, "a" => 2} generates a duplicate key,
671
+ # which json 3 refuses by default (older versions ignore the option).
672
+ JSON.parse(json, max_nesting: false, allow_duplicate_key: true)
673
+ rescue StandardError
674
+ # Only the shrink's own text can fail here (an escaped lone
675
+ # surrogate half, which json refuses to parse): it is JSON-shaped.
676
+ raise if degraded.nil?
677
+
678
+ degraded
679
+ end
680
+ shrunk_json, shrunk, truncated = Shrink.serialize_payload(wire, Shrink::MAX_PAYLOAD_BYTES, type_name)
681
+ return [json, degraded.nil?] unless truncated
682
+
683
+ size = shrunk.is_a?(Hash) && shrunk["bytes"].is_a?(Integer) ? shrunk["bytes"] : "unknown"
684
+ limit = Shrink::MAX_PAYLOAD_BYTES / 1024
685
+ if Shrink.valid_for?(type_name, shrunk)
686
+ @warner.warn("payload-budget:#{type_name}",
687
+ "an event of #{size} bytes was shrunk to a preview (the debugger stores at most " \
688
+ "#{limit} KB per payload)")
689
+ else
690
+ @warner.warn("payload-invalid:#{type_name}",
691
+ "a #{type_name} event of #{size} bytes could not be shrunk to a valid event (the debugger " \
692
+ "stores at most #{limit} KB per payload, and this payload is not a valid #{type_name} " \
693
+ "event); the debugger will drop it")
694
+ end
695
+ [shrunk_json, false]
696
+ end
697
+
698
+ # The input.toolSchemas of a node.started as the integration built it
699
+ # (start_node's input before sanitize copies it), handed back to
700
+ # Support.capture_tools' memory.
701
+ def release_tool_schemas_of(payload, raw_input)
702
+ input = raw_input.equal?(NO_RAW_INPUT) ? (payload.is_a?(Hash) ? payload["input"] : nil) : raw_input
703
+ return unless input.is_a?(Hash)
704
+
705
+ Integrations::Support.release_tool_schemas(input["toolSchemas"] || input[:toolSchemas])
706
+ rescue StandardError
707
+ nil
708
+ end
709
+
710
+ # JSON.generate(envelope with payload) from its head and the payload text.
711
+ def frame_with_payload(envelope, payload_json)
712
+ envelope.delete("payload")
713
+ head = Protocol.serialize_envelope(envelope)
714
+ text = payload_json.encoding == Encoding::UTF_8 ? payload_json : payload_json.dup.force_encoding(Encoding::UTF_8)
715
+ "#{head.byteslice(0, head.bytesize - 1)},\"payload\":#{text}}"
716
+ end
717
+
718
+ # The ring buffer refused a frame larger than its whole byte budget.
719
+ def warn_unbuffered(outcome, bytes)
720
+ limit = "max_buffer_bytes is #{@buffer.max_bytes}"
721
+ if outcome == :sent
722
+ @warner.warn("buffer-oversize-sent",
723
+ "an event of #{bytes} bytes is larger than the whole replay buffer (#{limit}); it was " \
724
+ "sent but not kept for replay")
725
+ else
726
+ @warner.warn("buffer-oversize-lost",
727
+ "dropped an event of #{bytes} bytes: it is larger than the whole replay buffer (#{limit}) " \
728
+ "and the debugger is not attached; the recorded run is incomplete. Raise `max_buffer_bytes`")
729
+ end
730
+ rescue StandardError
731
+ nil
732
+ end
733
+
734
+ NO_RAW_INPUT = Object.new.freeze
735
+ private_constant :NO_RAW_INPUT
736
+
737
+ # [kind, node_id, name, fingerprint] for a WATCHED node.started (loop rule
738
+ # v3), else nil. Each field is read once, in its own rescue, as the
739
+ # TypeScript session does: a kind that cannot be read touches no streak
740
+ # (which one would it be?); a nodeId/name that cannot be read, or an input
741
+ # whose read raises, yields an UNREADABLE fingerprint, which clears the
742
+ # kind's streak (rule 3) — never counted as identical, never raised.
743
+ def loop_call_for(payload, raw_input)
744
+ return nil unless @loop_guard.enabled? && Hash === payload
745
+
746
+ kind = begin
747
+ payload["kind"]
748
+ rescue StandardError
749
+ return nil
750
+ end
751
+ node_id, name = begin
752
+ [payload["nodeId"], payload["name"]]
753
+ rescue StandardError
754
+ [nil, nil]
755
+ end
756
+ return nil unless @loop_guard.watched?(kind, node_id, name)
757
+
758
+ input =
759
+ if raw_input.equal?(NO_RAW_INPUT)
760
+ begin
761
+ payload["input"]
762
+ rescue StandardError
763
+ LoopGuard::UNREADABLE
764
+ end
765
+ else
766
+ raw_input
767
+ end
768
+ [kind, node_id, name, @loop_guard.fingerprint(node_id, input)]
769
+ rescue StandardError
770
+ nil
771
+ end
772
+
773
+ def record_loop(run_id, loop_call, seq, fingerprint = loop_call[3])
774
+ @loop_guard.record(run_id, loop_call[0], loop_call[1], loop_call[2], fingerprint, seq)
775
+ rescue StandardError
776
+ nil
777
+ end
778
+
779
+ # The built-in loop breakpoint: only at `before`, only in mode pause, only
780
+ # when attached (the caller checked).
781
+ def consult_loop(point, node)
782
+ return nil unless point == "before" && @loop_guard.enabled? && @loop_guard.mode == "pause"
783
+
784
+ @loop_guard.consult(resolve_run_id, node.kind, node.node_id, node.name)
785
+ rescue StandardError
786
+ nil
787
+ end
788
+
789
+ # A looping agent is never silent: when nothing will hold it — no debugger
790
+ # attached, or mode warn — say so once per streak. Names the tool, never an
791
+ # argument.
792
+ def warn_loop(run_id, loop_call, record)
793
+ return if @loop_guard.mode == "pause" && @transport.attached?
794
+
795
+ kind, node_id, name = loop_call
796
+ node_id = node_id.to_s if node_id.is_a?(Symbol)
797
+ name = name.is_a?(String) || name.is_a?(Symbol) ? name.to_s : node_id
798
+ return unless @loop_guard.claim_warning(run_id, node_id, kind)
799
+
800
+ because =
801
+ if @loop_guard.mode == "warn"
802
+ "GRAPHMIND_ON_LOOP=warn, so it is not being held"
803
+ else
804
+ "no debugger is attached to hold it (start `npx graphmind-ai` to pause it there)"
805
+ end
806
+ @warner.warn(
807
+ "loop:#{node_id}",
808
+ "possible loop: #{name} (#{node_id}) was called #{record.repeats}\u00d7 in a row with " \
809
+ "identical arguments; #{because}. Polling on purpose? add it to " \
810
+ "loop_guard: { allow_nodes: [...] } or GRAPHMIND_LOOP_ALLOW; GRAPHMIND_ON_LOOP=off silences this"
811
+ )
812
+ rescue StandardError
813
+ nil
814
+ end
815
+
816
+ # exec.paused.loop as it may leave the process. The fingerprint is an
817
+ # unsalted digest of the node's input: when a switch hides that input
818
+ # (hide_inputs, or hide_tool_args on a tool) a low-entropy argument would be
819
+ # a dictionary attack away from it, so it is hidden too.
820
+ def loop_on_wire(info, node)
821
+ wire = info.to_wire
822
+ switches = @redactor.switches
823
+ # The literal's own == (a Symbol counts as its name): a String subclass that
824
+ # lies in #== must not decide that a hidden input's digest is safe to send.
825
+ tool = Redaction.wire_eq?(node.kind, "tool")
826
+ wire["fingerprint"] = Redaction::REDACTED if switches.hide_inputs || (switches.hide_tool_args && tool)
827
+ wire
828
+ end
829
+
830
+ # Held time is not run time. Track node instances as they start, pin gate
831
+ # holds to them (see HeldLedger) and stamp the total onto node.finished /
832
+ # node.error as the loose field `heldMs`. `durationMs` is left as measured
833
+ # (wall clock, held time included) apart from being normalised to the wire
834
+ # contract; "ran" is `durationMs - heldMs`. A `heldMs` the caller already
835
+ # set wins. Any failure leaves the payload untouched.
836
+ def with_held_time(type, payload, run_id)
837
+ node_id = payload["nodeId"]
838
+ return payload unless node_id.is_a?(String)
839
+
840
+ instance_id = payload["instanceId"]
841
+ instance_id = nil unless instance_id.is_a?(String)
842
+ case type
843
+ when "node.started"
844
+ @ledger.started(run_id, node_id, instance_id, payload["parentId"]) if instance_id
845
+ payload
846
+ when "node.error"
847
+ @ledger.errored(run_id, node_id, instance_id)
848
+ return payload if payload["heldMs"].is_a?(Numeric)
849
+
850
+ held = @ledger.peek(run_id, node_id, instance_id)
851
+ held.nil? ? payload : payload.merge("heldMs" => held)
852
+ when "node.finished"
853
+ out = payload.dup
854
+ out["durationMs"] = Clock.normalize_duration_ms(out["durationMs"]) if out.key?("durationMs")
855
+ held = @ledger.finished(run_id, node_id, instance_id)
856
+ out["heldMs"] = held if !held.nil? && !out["heldMs"].is_a?(Numeric)
857
+ out
858
+ else
859
+ payload
860
+ end
861
+ rescue StandardError
862
+ payload
863
+ end
864
+
865
+ def warn_loss
866
+ @warner.warn(
867
+ "buffer-overflow",
868
+ "dropped #{@lost} event#{@lost == 1 ? '' : 's'} while the debugger was unreachable; " \
869
+ "the recorded run is incomplete. Raise `buffer_size` (currently #{@buffer.capacity}) " \
870
+ "or attach the debugger sooner"
871
+ )
872
+ end
873
+
874
+ def build_hello
875
+ seq, token = @mutex.synchronize do
876
+ value = @seq
877
+ @seq += 1
878
+ [value, @session_token]
879
+ end
880
+ payload = {
881
+ "versions" => { "protocol" => Protocol::PROTOCOL_VERSION, "client" => Graphmind::VERSION },
882
+ "capabilities" => Protocol::KNOWN_CAPABILITIES.dup,
883
+ "app" => @app_name,
884
+ "sdk" => @sdk
885
+ }
886
+ # Echoing the token from the last hello.ack is what lets the debugger
887
+ # recognise a reconnect as the SAME app, and so refuse writes to our runs
888
+ # from any other local process. Absent on a first connection.
889
+ payload["resumeToken"] = token if token
890
+ Protocol.serialize_envelope(
891
+ Protocol.create_envelope("hello", payload, seq, Protocol::WILDCARD_RUN_ID)
892
+ )
893
+ end
894
+
895
+ def handle_attached(ack)
896
+ # What THIS debugger implements (0.6.0+). Not the echoed `capabilities`.
897
+ hub = ack["hubCapabilities"]
898
+ @hub_capabilities = hub.is_a?(Array) ? hub.select { |entry| entry.is_a?(String) }.freeze : nil
899
+ @engine.arm(ack["breakpoints"], ack["mode"])
900
+ @mutex.synchronize do
901
+ @attached_mirror = true
902
+ # Kept across reconnects on purpose (see build_hello). Only ever
903
+ # replaced, never cleared on detach: surviving the drop is the point.
904
+ token = ack["sessionToken"]
905
+ @session_token = token if token.is_a?(String) && !token.empty?
906
+ # Replay-on-attach, oldest first, under the lock: a host thread
907
+ # emitting right now must queue *after* the replay, not in the middle
908
+ # of it. Envelopes keep their original `seq`, so the viewer
909
+ # deduplicates on (runId, seq) — decisions.md #5.
910
+ @buffer.to_a.each { |frame| @transport.enqueue(frame) }
911
+ end
912
+ rescue StandardError => e
913
+ @warner.warn("attach", "internal error while attaching", e)
914
+ ensure
915
+ # Only after arming: a resolved ready() guarantees gates can pause.
916
+ settle_ready
917
+ end
918
+
919
+ def handle_detached
920
+ @mutex.synchronize { @attached_mirror = false }
921
+ # FAIL-OPEN: no debugger, no holds. Forget its breakpoints, mode and
922
+ # capabilities too; the next hello.ack re-arms them.
923
+ @hub_capabilities = nil
924
+ @engine.disarm
925
+ @engine.release_all
926
+ nil
927
+ rescue StandardError => e
928
+ @warner.warn("detach", "internal error while detaching", e)
929
+ end
930
+
931
+ def handle_control(envelope)
932
+ payload = envelope["payload"] || {}
933
+ case envelope["type"]
934
+ when "exec.resume"
935
+ handle_resume(payload) if payload.is_a?(Hash)
936
+ when "breakpoint.set"
937
+ @engine.add_breakpoint(payload["matcher"])
938
+ when "breakpoint.clear"
939
+ @engine.remove_breakpoint(payload["matcher"])
940
+ when "mode.set"
941
+ @engine.set_mode(payload["mode"])
942
+ end
943
+ # Events echoed back, duplicate handshakes, future additions: ignore.
944
+ nil
945
+ rescue StandardError => e
946
+ @warner.warn("control", "internal error handling a control frame", e)
947
+ end
948
+
949
+ def settle_ready
950
+ @ready_mutex.synchronize { @ready_cv.broadcast }
951
+ end
952
+
953
+ # An exec.resume for a held gate (0.6.0 rules, W1 scope: this gem does not
954
+ # announce `edit-input`):
955
+ # * `requestId` (any String) is echoed on the exec.resumed / exec.refused
956
+ # it causes, so the debugger can correlate its answer;
957
+ # * an edited `input` is refused (`disabled`) and the gate stays held — it
958
+ # is never run as a plain resume, which would silently drop the edit;
959
+ # * the inject guard: an output holding the redaction placeholder or a
960
+ # truncated preview (EditGuard) is refused and the gate stays held,
961
+ # under every debugger; a 0.5 debugger does not show exec.refused, so
962
+ # the app's log says why too.
963
+ # Unknown pauses and unknown actions are ignored.
964
+ def handle_resume(payload)
965
+ pause_id = payload["pauseId"]
966
+ action = payload["action"]
967
+ return unless pause_id.is_a?(String) && action.is_a?(String) && Protocol::RESUME_ACTIONS.include?(action)
968
+
969
+ gate = @engine.peek(pause_id)
970
+ return if gate.nil?
971
+
972
+ raw = payload["requestId"]
973
+ request_id = raw.is_a?(String) ? raw : nil
974
+ if payload.key?("input")
975
+ emit_refused(gate, "disabled",
976
+ "this app cannot run a call with an edited input (the Ruby SDK does not support input edits)",
977
+ request_id)
978
+ return
979
+ end
980
+ if action == "inject"
981
+ refusal = EditGuard.proposed_value_refusal(payload["output"])
982
+ unless refusal.nil?
983
+ emit_refused(gate, refusal.code, refusal.message, request_id)
984
+ if @hub_capabilities.nil?
985
+ @warner.warn("inject-refused",
986
+ "refused an injected value: #{refusal.message}. The call is still paused " \
987
+ "(inject the full value, or continue, retry or abort); this debugger does not " \
988
+ "show refusals — upgrade it to see them there")
989
+ end
990
+ return
991
+ end
992
+ end
993
+ @engine.resume(pause_id, action, payload["output"], request_id: request_id)
994
+ end
995
+
996
+ # exec.refused, redacted by the paused node's kind.
997
+ def emit_refused(gate, code, message, request_id)
998
+ payload = { "pauseId" => gate[:pause_id], "code" => code }
999
+ payload["message"] = message unless message.nil?
1000
+ payload["requestId"] = request_id unless request_id.nil?
1001
+ emit_or_warn("exec.refused", payload, gate[:run_id], gate[:node].kind)
1002
+ end
1003
+
1004
+ # Why a hold that no built-in breakpoint raised holds (`matchedReason` in
1005
+ # the TypeScript client): at an `error` point, "error" (the pause-on-error
1006
+ # breakpoint, or step mode stopping on an error); elsewhere "breakpoint"
1007
+ # when one of the debugger's breakpoints matches, else "step".
1008
+ def matched_reason(point, node)
1009
+ return "error" if point == "error"
1010
+
1011
+ @engine.breakpoints.any? { |matcher| @engine.matcher_matches(matcher, point, node) } ? "breakpoint" : "step"
1012
+ rescue StandardError
1013
+ "breakpoint"
1014
+ end
1015
+
1016
+ # exec.paused: the 0.5 fields in their 0.5 order, then `reason` — on every
1017
+ # hold (0.6.0): "loop" with `loop`, else "breakpoint" / "step" / "error"
1018
+ # from what the debugger armed (0.5 hubs accept all four) — then
1019
+ # `instanceId` when the caller named the held execution. The TypeScript
1020
+ # client's order (`pausedPayload`); this gem never offers `editable`.
1021
+ def on_paused(pause_id, node, point, run_id, reason = nil)
1022
+ swallow { @ledger.hold_opened(pause_id, run_id, node.node_id, point) }
1023
+ payload = { "pauseId" => pause_id, "nodeId" => node.node_id, "point" => point }
1024
+ if reason.is_a?(LoopGuard::Info)
1025
+ swallow do
1026
+ loop_wire = loop_on_wire(reason, node)
1027
+ payload["reason"] = "loop"
1028
+ payload["loop"] = loop_wire
1029
+ end
1030
+ elsif MATCHED_REASONS.include?(reason)
1031
+ payload["reason"] = reason
1032
+ end
1033
+ instance_id = node.respond_to?(:instance_id) ? node.instance_id : nil
1034
+ payload["instanceId"] = instance_id if instance_id.is_a?(String) && !instance_id.empty?
1035
+ emit_or_warn("exec.paused", payload, run_id)
1036
+ end
1037
+
1038
+ def on_resumed(pause_id, node, action, run_id, request_id = nil)
1039
+ swallow { @ledger.hold_closed(pause_id) }
1040
+ payload = { "pauseId" => pause_id, "action" => action }
1041
+ payload["requestId"] = request_id unless request_id.nil?
1042
+ emit_or_warn("exec.resumed", payload, run_id, node.kind)
1043
+ end
1044
+
1045
+ def emit_or_warn(type, payload, run_id, node_kind = nil)
1046
+ return unless active?
1047
+
1048
+ emit_internal(type, payload, run_id, node_kind: node_kind)
1049
+ rescue StandardError => e
1050
+ @warner.warn("emit", "internal error emitting a gate event", e)
1051
+ end
1052
+
1053
+ def apply_decision(decision)
1054
+ if decision.abort?
1055
+ ctx = current_run
1056
+ ctx&.abort!(Graphmind::AbortError.new)
1057
+ end
1058
+ decision
1059
+ end
1060
+
1061
+ def begin_run(ctx, name, meta, node_id)
1062
+ return unless active?
1063
+
1064
+ ensure_started
1065
+ payload_meta = { "name" => name.to_s }
1066
+ payload_meta.merge!(stringify(@meta)) if @meta
1067
+ payload_meta.merge!(stringify(meta)) if meta
1068
+ emit_internal("run.started",
1069
+ { "app" => @app_name, "sdk" => @sdk, "meta" => payload_meta }, ctx.run_id)
1070
+ emit_internal("node.started",
1071
+ { "nodeId" => node_id, "kind" => "agent", "name" => name.to_s,
1072
+ "instanceId" => ctx.run_id }, ctx.run_id)
1073
+ rescue StandardError => e
1074
+ @warner.warn("run-start", "internal error starting a run", e)
1075
+ end
1076
+
1077
+ def end_run(ctx, node_id, started, error)
1078
+ return unless active?
1079
+
1080
+ aborted = ctx.aborted? || Errors.abort_error?(error)
1081
+ status = if aborted
1082
+ "aborted"
1083
+ else
1084
+ error.nil? ? "ok" : "error"
1085
+ end
1086
+ duration_ms = Clock.elapsed_ms(started)
1087
+ if error && !aborted
1088
+ emit_internal("node.error",
1089
+ { "nodeId" => node_id, "instanceId" => ctx.run_id,
1090
+ "error" => Errors.to_error_info(error) }, ctx.run_id)
1091
+ end
1092
+ @batcher.flush_all
1093
+ emit_internal("node.finished",
1094
+ { "nodeId" => node_id, "instanceId" => ctx.run_id,
1095
+ "durationMs" => duration_ms, "status" => status }, ctx.run_id)
1096
+ payload = { "status" => status }
1097
+ payload["error"] = Errors.to_error_info(error) if error && !aborted
1098
+ emit_internal("run.finished", payload, ctx.run_id)
1099
+ rescue StandardError => e
1100
+ @warner.warn("run-finish", "internal error finishing a run", e)
1101
+ end
1102
+
1103
+ def stringify(hash)
1104
+ return {} unless hash.is_a?(Hash)
1105
+
1106
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
1107
+ end
1108
+
1109
+ # Values reach the viewer as JSON, and a host object that cannot be
1110
+ # serialized must never break an emit. Anything JSON cannot express
1111
+ # degrades to its #inspect string.
1112
+ MAX_PREVIEW = 8000
1113
+ # Nesting walked before the depth marker — the loop fingerprint's bound.
1114
+ # Deeper values used to exhaust the stack here, or (below ~97 levels) make
1115
+ # JSON.generate refuse the whole envelope (max_nesting 100), losing the event.
1116
+ MAX_SANITIZE_DEPTH = 64
1117
+ # Entries of an Array or Hash recorded before the rest is counted.
1118
+ MAX_SANITIZE_ITEMS = 200
1119
+
1120
+ # Every bound leaves a marker on the shared truncation list (the Python
1121
+ # SDK's spellings, `PYTHON_PREVIEW_MARKERS` in edit-input.ts; EditGuard and
1122
+ # the hub refuse them), so a pre-filled copy of a cut recording is never
1123
+ # injected as if it were the whole value (refute-security S5).
1124
+ SANITIZE_ELLIPSIS = "…"
1125
+ SANITIZE_DEPTH_MARKER = "#{SANITIZE_ELLIPSIS}[depth limit]".freeze
1126
+ SANITIZE_TEXT_SUFFIX = "#{SANITIZE_ELLIPSIS}[truncated]".freeze
1127
+
1128
+ # A cycle (a Hash holding itself, a #to_h that returns a Hash holding the
1129
+ # object) becomes "[circular]" and runaway nesting "…[depth limit]":
1130
+ # before, both recursed until SystemStackError — not a StandardError —
1131
+ # which escaped every rescue into the host's own call.
1132
+ def sanitize(value, depth = 0, path = nil)
1133
+ case value
1134
+ when nil, true, false, Integer, String then return value
1135
+ when Float then return value.finite? ? value : value.to_s
1136
+ when Symbol then return value.to_s
1137
+ end
1138
+ return SANITIZE_DEPTH_MARKER if depth > MAX_SANITIZE_DEPTH
1139
+
1140
+ path ||= {}.compare_by_identity
1141
+ return "[circular]" if path.key?(value)
1142
+
1143
+ path[value] = true
1144
+ begin
1145
+ sanitize_container(value, depth, path)
1146
+ ensure
1147
+ path.delete(value)
1148
+ end
1149
+ end
1150
+
1151
+ def sanitize_container(value, depth, path)
1152
+ case value
1153
+ when Array
1154
+ out = value.first(MAX_SANITIZE_ITEMS).map { |v| sanitize(v, depth + 1, path) }
1155
+ out << "#{SANITIZE_ELLIPSIS}[#{value.size - MAX_SANITIZE_ITEMS} more]" if value.size > MAX_SANITIZE_ITEMS
1156
+ out
1157
+ when Hash
1158
+ out = value.first(MAX_SANITIZE_ITEMS).each_with_object({}) do |(k, v), acc|
1159
+ acc[k.to_s] = sanitize(v, depth + 1, path)
1160
+ end
1161
+ out[SANITIZE_ELLIPSIS] = "[#{value.size - MAX_SANITIZE_ITEMS} more keys]" if value.size > MAX_SANITIZE_ITEMS
1162
+ out
1163
+ else
1164
+ if value.respond_to?(:to_h)
1165
+ begin
1166
+ return sanitize(value.to_h, depth + 1, path)
1167
+ rescue StandardError, SystemStackError
1168
+ nil
1169
+ end
1170
+ end
1171
+ text = begin
1172
+ value.inspect
1173
+ rescue StandardError, SystemStackError
1174
+ value.class.name.to_s
1175
+ end
1176
+ text.length > MAX_PREVIEW ? "#{text[0, MAX_PREVIEW]}#{SANITIZE_TEXT_SUFFIX}" : text
1177
+ end
1178
+ end
1179
+ end
1180
+ end