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,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
|