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