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,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protocol"
|
|
4
|
+
|
|
5
|
+
module Graphmind
|
|
6
|
+
# How a gate was released.
|
|
7
|
+
class GateDecision
|
|
8
|
+
attr_reader :action, :output
|
|
9
|
+
|
|
10
|
+
def initialize(action, output = nil)
|
|
11
|
+
@action = action
|
|
12
|
+
@output = output
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def continue? = @action == "continue"
|
|
17
|
+
def retry? = @action == "retry"
|
|
18
|
+
def inject? = @action == "inject"
|
|
19
|
+
def abort? = @action == "abort"
|
|
20
|
+
|
|
21
|
+
def ==(other)
|
|
22
|
+
other.is_a?(GateDecision) && other.action == @action && other.output == @output
|
|
23
|
+
end
|
|
24
|
+
alias eql? ==
|
|
25
|
+
|
|
26
|
+
def hash = [@action, @output].hash
|
|
27
|
+
|
|
28
|
+
def to_s = @action == "inject" ? "GateDecision(inject, #{@output.inspect})" : "GateDecision(#{@action})"
|
|
29
|
+
alias inspect to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Shared instance returned on every fast path (detached / nothing matching).
|
|
33
|
+
# Allocating nothing on the hot path is the whole point.
|
|
34
|
+
CONTINUE = GateDecision.new("continue")
|
|
35
|
+
|
|
36
|
+
# The logical node a gate belongs to. `instance_id`: the execution the gate
|
|
37
|
+
# holds (its node.started instanceId), when the caller knows it — sent as
|
|
38
|
+
# exec.paused.instanceId (0.6.0) so a debugger can tell parallel calls of one
|
|
39
|
+
# node apart. Breakpoints never match on it.
|
|
40
|
+
class GateNode
|
|
41
|
+
attr_reader :node_id, :kind, :name, :instance_id
|
|
42
|
+
|
|
43
|
+
def initialize(node_id, kind, name, instance_id = nil)
|
|
44
|
+
@node_id = node_id
|
|
45
|
+
@kind = kind
|
|
46
|
+
@name = name
|
|
47
|
+
@instance_id = instance_id
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_s = "GateNode(#{@node_id}, #{@kind}, #{@name})"
|
|
52
|
+
alias inspect to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# One held gate. The waiting thread blocks in #wait; the transport thread
|
|
56
|
+
# calls #settle. A Mutex + ConditionVariable (rather than Queue#pop(timeout:))
|
|
57
|
+
# keeps the gem usable on Ruby 3.1.
|
|
58
|
+
class Hold
|
|
59
|
+
attr_reader :pause_id
|
|
60
|
+
|
|
61
|
+
def initialize(pause_id)
|
|
62
|
+
@pause_id = pause_id
|
|
63
|
+
@mutex = Mutex.new
|
|
64
|
+
@cv = ConditionVariable.new
|
|
65
|
+
@decision = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Block up to `timeout` seconds. Returns the decision, or nil on timeout so
|
|
69
|
+
# the caller can re-check fail-open conditions.
|
|
70
|
+
def wait(timeout)
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
@cv.wait(@mutex, timeout) if @decision.nil?
|
|
73
|
+
@decision
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def settle(decision)
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
return false unless @decision.nil?
|
|
80
|
+
|
|
81
|
+
@decision = decision
|
|
82
|
+
@cv.broadcast
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def settled? = !@mutex.synchronize { @decision }.nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Gate engine: cooperative pause points inside the instrumented process.
|
|
91
|
+
#
|
|
92
|
+
# Port of packages/client/src/gate-engine.ts, made thread-safe because a Ruby
|
|
93
|
+
# agent may be running on many threads at once (Puma, Sidekiq) while the
|
|
94
|
+
# transport lives on its own background thread.
|
|
95
|
+
#
|
|
96
|
+
# The session owns the decision of WHETHER to gate (attached? matching
|
|
97
|
+
# breakpoint? step mode?); this engine owns the bookkeeping of HELD gates:
|
|
98
|
+
# registration, resume routing, pause timeouts and fail-open release.
|
|
99
|
+
#
|
|
100
|
+
# Fail-open invariants:
|
|
101
|
+
# * release_all releases every held gate with `continue` (on disconnect,
|
|
102
|
+
# on dispose, and from the at_exit hook);
|
|
103
|
+
# * an optional per-gate pause timeout auto-continues a gate nobody
|
|
104
|
+
# resumes.
|
|
105
|
+
class GateEngine
|
|
106
|
+
def initialize(on_paused:, on_resumed:, new_pause_id:, pause_timeout: nil)
|
|
107
|
+
@on_paused = on_paused
|
|
108
|
+
@on_resumed = on_resumed
|
|
109
|
+
@new_pause_id = new_pause_id
|
|
110
|
+
@pause_timeout = pause_timeout
|
|
111
|
+
@mutex = Mutex.new
|
|
112
|
+
@breakpoints = []
|
|
113
|
+
@mode = "run"
|
|
114
|
+
@held = {}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# -- viewer state --------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
# Adopt the viewer's full debug state (from hello.ack).
|
|
120
|
+
def arm(breakpoints, mode)
|
|
121
|
+
@mutex.synchronize do
|
|
122
|
+
@breakpoints = Array(breakpoints).select { |m| m.is_a?(Hash) }.map { |m| normalize(m) }
|
|
123
|
+
@mode = Protocol::RUN_MODES.include?(mode) ? mode : "run"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Drop viewer state (on detach). Held gates are released separately.
|
|
128
|
+
def disarm
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
@breakpoints = []
|
|
131
|
+
@mode = "run"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def mode = @mutex.synchronize { @mode }
|
|
136
|
+
|
|
137
|
+
def set_mode(mode)
|
|
138
|
+
return unless Protocol::RUN_MODES.include?(mode)
|
|
139
|
+
|
|
140
|
+
@mutex.synchronize { @mode = mode }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def add_breakpoint(matcher)
|
|
144
|
+
return unless matcher.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
normalized = normalize(matcher)
|
|
147
|
+
@mutex.synchronize do
|
|
148
|
+
@breakpoints << normalized unless @breakpoints.any? { |m| matcher_equals(m, normalized) }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def remove_breakpoint(matcher)
|
|
153
|
+
return unless matcher.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
normalized = normalize(matcher)
|
|
156
|
+
@mutex.synchronize do
|
|
157
|
+
@breakpoints = @breakpoints.reject { |m| matcher_equals(m, normalized) }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def breakpoints = @mutex.synchronize { @breakpoints.map(&:dup) }
|
|
162
|
+
|
|
163
|
+
# -- decisions -----------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
# Step mode pauses at every before/error point; run mode only on a matching
|
|
166
|
+
# breakpoint (`after` always needs an explicit one — decisions.md #2).
|
|
167
|
+
def should_pause?(point, node)
|
|
168
|
+
@mutex.synchronize do
|
|
169
|
+
return true if @mode == "step" && point != "after"
|
|
170
|
+
|
|
171
|
+
@breakpoints.any? { |matcher| matcher_matches(matcher, point, node) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# -- holds ---------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
# Register a held gate. Call only after #should_pause? (or for a built-in
|
|
178
|
+
# breakpoint such as the loop hold, whose details ride `reason` to
|
|
179
|
+
# on_paused — handed over per call, never shared between threads).
|
|
180
|
+
def hold(point, node, run_id, reason = nil)
|
|
181
|
+
pause_id = @new_pause_id.call
|
|
182
|
+
hold = Hold.new(pause_id)
|
|
183
|
+
timer = nil
|
|
184
|
+
@mutex.synchronize do
|
|
185
|
+
@held[pause_id] = { hold: hold, node: node, point: point, run_id: run_id }
|
|
186
|
+
end
|
|
187
|
+
if @pause_timeout
|
|
188
|
+
timer = Thread.new(pause_id) do |id|
|
|
189
|
+
sleep(@pause_timeout)
|
|
190
|
+
settle(id, CONTINUE, "continue")
|
|
191
|
+
end
|
|
192
|
+
timer.name = "graphmind-pause-timeout" if timer.respond_to?(:name=)
|
|
193
|
+
@mutex.synchronize { @held[pause_id][:timer] = timer if @held.key?(pause_id) }
|
|
194
|
+
end
|
|
195
|
+
# Emitted after registration so a resume racing back always finds the gate.
|
|
196
|
+
safely { @on_paused.call(pause_id, node, point, run_id, reason) }
|
|
197
|
+
hold
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Route a viewer exec.resume to its held gate. Unknown ids are ignored.
|
|
201
|
+
# `request_id` (0.6.0) is echoed on the exec.resumed this release emits.
|
|
202
|
+
def resume(pause_id, action, output = nil, request_id: nil)
|
|
203
|
+
return false unless Protocol::RESUME_ACTIONS.include?(action)
|
|
204
|
+
|
|
205
|
+
decision = action == "inject" ? GateDecision.new("inject", output) : GateDecision.new(action)
|
|
206
|
+
settle(pause_id, decision, action, request_id)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# A held gate as the session may inspect it — {pause_id:, node:, point:,
|
|
210
|
+
# run_id:}, a frozen copy — or nil when no gate has this id.
|
|
211
|
+
def peek(pause_id)
|
|
212
|
+
@mutex.synchronize do
|
|
213
|
+
entry = @held[pause_id]
|
|
214
|
+
next nil if entry.nil?
|
|
215
|
+
|
|
216
|
+
{ pause_id: pause_id, node: entry[:node], point: entry[:point], run_id: entry[:run_id] }.freeze
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# FAIL-OPEN: release every held gate with `continue`. Returns the count.
|
|
221
|
+
def release_all
|
|
222
|
+
ids = @mutex.synchronize { @held.keys }
|
|
223
|
+
ids.count { |pause_id| settle(pause_id, CONTINUE, "continue") }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Drop a gate whose waiter gave up (fail-open poll, interrupt). Emits
|
|
227
|
+
# exec.resumed like any other release so the viewer's pause history stays
|
|
228
|
+
# reconstructable.
|
|
229
|
+
def discard(pause_id)
|
|
230
|
+
settle(pause_id, CONTINUE, "continue")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def held_count = @mutex.synchronize { @held.size }
|
|
234
|
+
|
|
235
|
+
# -- matching ------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
# Every present matcher field must match; absent fields match anything.
|
|
238
|
+
def matcher_matches(matcher, point, node)
|
|
239
|
+
return false if (matcher["point"] || "before") != point
|
|
240
|
+
|
|
241
|
+
kind = matcher["kind"]
|
|
242
|
+
return false if kind && kind != node.kind
|
|
243
|
+
|
|
244
|
+
name = matcher["name"]
|
|
245
|
+
name.nil? || name == node.name
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def matcher_equals(a, b)
|
|
249
|
+
a["kind"] == b["kind"] && a["name"] == b["name"] && a["point"] == b["point"]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def normalize(matcher)
|
|
255
|
+
out = {}
|
|
256
|
+
%w[kind name point].each do |key|
|
|
257
|
+
value = matcher[key] || matcher[key.to_sym]
|
|
258
|
+
out[key] = value unless value.nil?
|
|
259
|
+
end
|
|
260
|
+
out
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def settle(pause_id, decision, action, request_id = nil)
|
|
264
|
+
entry = @mutex.synchronize { @held.delete(pause_id) }
|
|
265
|
+
return false if entry.nil?
|
|
266
|
+
|
|
267
|
+
timer = entry[:timer]
|
|
268
|
+
timer.kill if timer && timer != Thread.current
|
|
269
|
+
# The callback and the wake-up happen OUTSIDE the lock: the waiting
|
|
270
|
+
# thread must never contend with the transport to get moving again.
|
|
271
|
+
released = entry[:hold].settle(decision)
|
|
272
|
+
safely { @on_resumed.call(pause_id, entry[:node], action, entry[:run_id], request_id) }
|
|
273
|
+
released
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def safely
|
|
277
|
+
yield
|
|
278
|
+
rescue StandardError
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "clock"
|
|
4
|
+
|
|
5
|
+
module Graphmind
|
|
6
|
+
# Held-time ledger: how much of a node instance's wall-clock duration was the
|
|
7
|
+
# *debugger* holding it, rather than the node running.
|
|
8
|
+
#
|
|
9
|
+
# Port of packages/client/src/held-ledger.ts — read that file's header for
|
|
10
|
+
# the full rationale. In short: `durationMs` keeps its meaning (wall clock,
|
|
11
|
+
# held time INCLUDED, so stored runs and importers are unaffected) and the
|
|
12
|
+
# loose field `heldMs` on `node.finished` / `node.error` is the debugger's
|
|
13
|
+
# share; "ran" is `durationMs - heldMs`.
|
|
14
|
+
#
|
|
15
|
+
# Attribution pins a hold to one of the node's open instances when it opens:
|
|
16
|
+
# * `before` / `error` gates -> the most recently started open instance
|
|
17
|
+
# (for `error`, an instance `node.error` named is preferred);
|
|
18
|
+
# * `after` gates -> the oldest open instance;
|
|
19
|
+
# * no open instance -> not attributed (a hold that opens after an instance
|
|
20
|
+
# finished is not inside that instance's `durationMs`).
|
|
21
|
+
# The hold is also pinned to every open *ancestor* instance (following the
|
|
22
|
+
# `parentId` from `node.started`) and to the run's root instance — the node
|
|
23
|
+
# whose `instanceId` is the `runId`, which is how every SDK emits the
|
|
24
|
+
# `agent:<run>` node. Each instance accumulates the UNION of the intervals
|
|
25
|
+
# during which at least one hold pinned to it was open, so two children held
|
|
26
|
+
# at the same time do not count twice in their parent.
|
|
27
|
+
#
|
|
28
|
+
# Exact whenever executions of one logical node do not overlap; a documented
|
|
29
|
+
# heuristic for overlapping instances of the same node.
|
|
30
|
+
#
|
|
31
|
+
# Thread-safe (one mutex, never held while calling out), bounded, never raises.
|
|
32
|
+
class HeldLedger
|
|
33
|
+
DEFAULT_MAX_TRACKED_INSTANCES = 10_000
|
|
34
|
+
MAX_DEPTH = 64
|
|
35
|
+
|
|
36
|
+
Instance = Struct.new(:key, :node_key, :instance_id, :held_ms, :open_holds, :held_since, :errored)
|
|
37
|
+
|
|
38
|
+
def initialize(max_instances: DEFAULT_MAX_TRACKED_INSTANCES, clock: nil)
|
|
39
|
+
@max = [1, max_instances.to_i].max
|
|
40
|
+
@clock = clock || -> { Clock.now_ms }
|
|
41
|
+
@mutex = Mutex.new
|
|
42
|
+
@instances = {} # key -> Instance, insertion-ordered (oldest first)
|
|
43
|
+
@by_node = {} # [run_id, node_id] -> [Instance] in start order
|
|
44
|
+
@parent_by_node = {} # [run_id, node_id] -> parent node_id
|
|
45
|
+
@root_by_run = {} # run_id -> Instance whose instance_id == run_id
|
|
46
|
+
@holds = {} # pause_id -> [Instance] (target first, then ancestors)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# -- instance lifecycle --------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def started(run_id, node_id, instance_id, parent_id = nil)
|
|
52
|
+
node_key = [run_id, node_id]
|
|
53
|
+
key = [run_id, node_id, instance_id]
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
existing = @instances[key]
|
|
56
|
+
remove(existing) if existing
|
|
57
|
+
remove(@instances.values.first) if @instances.size >= @max
|
|
58
|
+
@parent_by_node.clear if @parent_by_node.size >= @max * 4
|
|
59
|
+
@parent_by_node[node_key] = parent_id if parent_id.is_a?(String) && parent_id != node_id
|
|
60
|
+
instance = Instance.new(key, node_key, instance_id, 0.0, 0, 0.0, false)
|
|
61
|
+
@instances[key] = instance
|
|
62
|
+
@root_by_run[run_id] = instance if instance_id == run_id
|
|
63
|
+
(@by_node[node_key] ||= []) << instance
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def errored(run_id, node_id, instance_id)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
instance = pick(run_id, node_id, instance_id)
|
|
71
|
+
instance.errored = true if instance
|
|
72
|
+
end
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Held so far for an open instance (a hold open right now included); nil when unknown.
|
|
77
|
+
def peek(run_id, node_id, instance_id)
|
|
78
|
+
now = @clock.call
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
instance = pick(run_id, node_id, instance_id)
|
|
81
|
+
instance && total(instance, now)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Close the instance and return its held total; nil when unknown. A hold
|
|
86
|
+
# still open against it is credited up to now (that time IS inside the
|
|
87
|
+
# `durationMs` just measured) and unpinned from it.
|
|
88
|
+
def finished(run_id, node_id, instance_id)
|
|
89
|
+
now = @clock.call
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
instance = pick(run_id, node_id, instance_id)
|
|
92
|
+
return nil unless instance
|
|
93
|
+
|
|
94
|
+
held = total(instance, now)
|
|
95
|
+
remove(instance)
|
|
96
|
+
held
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -- holds ---------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def hold_opened(pause_id, run_id, node_id, point)
|
|
103
|
+
now = @clock.call
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
instances = @by_node[[run_id, node_id]]
|
|
106
|
+
target = nil
|
|
107
|
+
if instances && !instances.empty?
|
|
108
|
+
if point == "after"
|
|
109
|
+
target = instances.first
|
|
110
|
+
else
|
|
111
|
+
target = instances.reverse.find(&:errored) if point == "error"
|
|
112
|
+
target ||= instances.last
|
|
113
|
+
target.errored = false if point == "error" # one hold per failure
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
# No open instance of the held node (a callback-style after/error gate
|
|
117
|
+
# fires AFTER node.finished): the hold is outside every instance of this
|
|
118
|
+
# node's durationMs and must not be charged to the next one — but the
|
|
119
|
+
# open ancestors and the run root are still running while the developer
|
|
120
|
+
# looks, and it IS inside theirs.
|
|
121
|
+
pinned = target ? [target] : []
|
|
122
|
+
# Ancestors: the newest open instance of each parent up the chain.
|
|
123
|
+
current = [run_id, node_id]
|
|
124
|
+
seen = { current => true }
|
|
125
|
+
MAX_DEPTH.times do
|
|
126
|
+
parent_id = @parent_by_node[current]
|
|
127
|
+
break if parent_id.nil?
|
|
128
|
+
|
|
129
|
+
parent_key = [run_id, parent_id]
|
|
130
|
+
break if seen[parent_key]
|
|
131
|
+
|
|
132
|
+
seen[parent_key] = true
|
|
133
|
+
parents = @by_node[parent_key]
|
|
134
|
+
pinned << parents.last if parents && !parents.empty?
|
|
135
|
+
current = parent_key
|
|
136
|
+
end
|
|
137
|
+
root = @root_by_run[run_id]
|
|
138
|
+
pinned << root if root && pinned.none? { |i| i.equal?(root) }
|
|
139
|
+
return nil if pinned.empty?
|
|
140
|
+
|
|
141
|
+
pinned.each do |instance|
|
|
142
|
+
instance.held_since = now if instance.open_holds.zero?
|
|
143
|
+
instance.open_holds += 1
|
|
144
|
+
end
|
|
145
|
+
@holds[pause_id] = pinned
|
|
146
|
+
end
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def hold_closed(pause_id)
|
|
151
|
+
now = @clock.call
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
pinned = @holds.delete(pause_id)
|
|
154
|
+
pinned&.each { |instance| release(instance, now) }
|
|
155
|
+
end
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# -- diagnostics ---------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def tracked_instances = @mutex.synchronize { @instances.size }
|
|
162
|
+
def open_holds = @mutex.synchronize { @holds.size }
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# Call with the mutex held.
|
|
167
|
+
|
|
168
|
+
def total(instance, now)
|
|
169
|
+
open_ms = instance.open_holds.positive? ? [0.0, now - instance.held_since].max : 0.0
|
|
170
|
+
Clock.normalize_duration_ms(instance.held_ms + open_ms)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def release(instance, now)
|
|
174
|
+
return if instance.open_holds.zero?
|
|
175
|
+
|
|
176
|
+
instance.open_holds -= 1
|
|
177
|
+
instance.held_ms += [0.0, now - instance.held_since].max if instance.open_holds.zero?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Exact instance when named; otherwise the newest open one for the node.
|
|
181
|
+
def pick(run_id, node_id, instance_id)
|
|
182
|
+
instances = @by_node[[run_id, node_id]]
|
|
183
|
+
return nil if instances.nil? || instances.empty?
|
|
184
|
+
# An unknown instanceId is not "the newest one".
|
|
185
|
+
return @instances[[run_id, node_id, instance_id]] unless instance_id.nil?
|
|
186
|
+
|
|
187
|
+
instances.last
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def remove(instance)
|
|
191
|
+
@instances.delete(instance.key)
|
|
192
|
+
run_id = instance.node_key[0]
|
|
193
|
+
@root_by_run.delete(run_id) if @root_by_run[run_id].equal?(instance)
|
|
194
|
+
siblings = @by_node[instance.node_key]
|
|
195
|
+
if siblings
|
|
196
|
+
siblings.delete_if { |i| i.equal?(instance) }
|
|
197
|
+
@by_node.delete(instance.node_key) if siblings.empty?
|
|
198
|
+
end
|
|
199
|
+
return unless instance.open_holds.positive?
|
|
200
|
+
|
|
201
|
+
@holds.each_key.to_a.each do |pause_id|
|
|
202
|
+
pinned = @holds[pause_id]
|
|
203
|
+
pinned.delete_if { |i| i.equal?(instance) }
|
|
204
|
+
@holds.delete(pause_id) if pinned.empty?
|
|
205
|
+
end
|
|
206
|
+
instance.open_holds = 0
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Graphmind
|
|
6
|
+
# Node identity (internal/decisions.md #1) and compact unique ids.
|
|
7
|
+
#
|
|
8
|
+
# `node_id` is stable per LOGICAL node, so the canvas renders one node per
|
|
9
|
+
# code location; `instance_id` distinguishes repeated executions. Concurrent
|
|
10
|
+
# executions of the same logical node are told apart purely by instance id —
|
|
11
|
+
# which is why every node.started / node.finished / node.error this gem
|
|
12
|
+
# emits carries one.
|
|
13
|
+
module Ids
|
|
14
|
+
# The single logical LLM node of a provider-SDK agent loop. Matches the
|
|
15
|
+
# TypeScript and Python adapters so the viewer renders every language's
|
|
16
|
+
# runs identically.
|
|
17
|
+
LLM_NODE_ID = "llm:step"
|
|
18
|
+
LLM_NODE_NAME = "step"
|
|
19
|
+
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@pid = Process.pid
|
|
22
|
+
@base = SecureRandom.hex(3)
|
|
23
|
+
@counter = 0
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def tool_node_id(name) = "tool:#{name}"
|
|
28
|
+
def agent_node_id(name) = "agent:#{name}"
|
|
29
|
+
def llm_node_id(name) = "llm:#{name}"
|
|
30
|
+
def chain_node_id(name) = "chain:#{name}"
|
|
31
|
+
|
|
32
|
+
# Globally unique id (runs).
|
|
33
|
+
def new_id(prefix)
|
|
34
|
+
"#{prefix}_#{SecureRandom.hex(6)}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Compact per-process unique id (pauses, node instances). Re-seeded after a
|
|
38
|
+
# fork so a Puma/Unicorn worker cannot mint ids that collide with its
|
|
39
|
+
# parent's.
|
|
40
|
+
def next_id(prefix)
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
if @pid != Process.pid
|
|
43
|
+
@pid = Process.pid
|
|
44
|
+
@base = SecureRandom.hex(3)
|
|
45
|
+
@counter = 0
|
|
46
|
+
end
|
|
47
|
+
@counter += 1
|
|
48
|
+
"#{prefix}_#{@base}_#{@counter}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|