msnav 0.2.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,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Msnav
4
+ # Cross-service code navigation over the coderag graph — a line-for-line
5
+ # port of coderag/navigation.py's Navigator, answering:
6
+ #
7
+ # definition(file, line0) where an outgoing call/publish LANDS in the
8
+ # other service's source (Faraday call -> Roda
9
+ # route, RabbitMQ publish -> consumer handler,
10
+ # route line -> the Process class it routes to)
11
+ # references(file, line0) who calls the endpoint under the cursor, from
12
+ # other services
13
+ # hover(file, line0) the target endpoint's doc for the call on the line
14
+ # file_targets(file) every cross-service jump point in a file
15
+ # (the editor's CodeLens data)
16
+ #
17
+ # Lines are 0-based at this boundary (LSP convention, same as the HTTP API);
18
+ # the index stores 1-based lines, converted on the way in/out. Locations are
19
+ # returned as {"path", "line", "character"} hashes, ready for the JSON API.
20
+ class Navigator
21
+ # edge kinds whose target is a cross-service / framework "definition"
22
+ DEF_EDGES = ["http_call", "publishes", "routes_to"].freeze
23
+
24
+ attr_reader :graph, :roots
25
+
26
+ def initialize(graph)
27
+ @graph = graph
28
+ # service name -> absolute root path
29
+ @roots = {}
30
+ graph.services.each do |_id, data|
31
+ name = data["name"]
32
+ root = data["root"]
33
+ @roots[name] = root if name && present?(root)
34
+ end
35
+ end
36
+
37
+ # Map an absolute path to [service_name, path_relative_to_service_root],
38
+ # longest matching root wins (nested service dirs). nil when the path is
39
+ # under no indexed service.
40
+ def locate(abs_path)
41
+ abs_path = File.expand_path(abs_path)
42
+ best = nil
43
+ @roots.each do |name, root|
44
+ root_abs = File.expand_path(root)
45
+ rel = begin
46
+ Pathname.new(abs_path).relative_path_from(Pathname.new(root_abs)).to_s
47
+ rescue ArgumentError
48
+ next
49
+ end
50
+ next if rel.start_with?("..")
51
+ best = [name, rel, root_abs] if best.nil? || root_abs.length > best[2].length
52
+ end
53
+ best && [best[0], best[1].tr(File::SEPARATOR, "/")]
54
+ end
55
+
56
+ def definition(abs_path, line0, _character = 0)
57
+ loc = locate(abs_path)
58
+ return [] if loc.nil?
59
+ service, rel = loc
60
+ line1 = line0 + 1
61
+ targets = []
62
+
63
+ # 1) cursor inside a class that makes outgoing calls / publishes
64
+ sym_id = covering_symbol(service, rel, line1)
65
+ unless sym_id.nil?
66
+ edges = @graph.out(sym_id, DEF_EDGES)
67
+ # prefer an edge whose call site is exactly on this line
68
+ exact = edges.select { |_t, e| e["line"] == line1 }
69
+ chosen = exact.empty? ? edges : exact
70
+ chosen.each do |tgt, edata|
71
+ kind = edata["kind"]
72
+ if kind == "publishes"
73
+ tnode = @graph.node(tgt) || {}
74
+ if tnode["kind"] == "topic"
75
+ targets.concat(publish_targets(tgt))
76
+ next
77
+ end
78
+ end
79
+ t = node_target(tgt)
80
+ targets << t if t
81
+ end
82
+ end
83
+
84
+ # 2) cursor on a route line -> the Process it routes to (DI indirection)
85
+ ep_id = endpoint_at(service, rel, line1)
86
+ unless ep_id.nil?
87
+ @graph.out(ep_id, ["routes_to"]).each do |tgt, _edata|
88
+ t = node_target(tgt)
89
+ targets << t if t
90
+ end
91
+ end
92
+
93
+ dedupe(targets.map { |t| to_location(t) })
94
+ end
95
+
96
+ # Cross-service callers of the endpoint under the cursor.
97
+ def references(abs_path, line0, _character = 0)
98
+ loc = locate(abs_path)
99
+ return [] if loc.nil?
100
+ service, rel = loc
101
+ line1 = line0 + 1
102
+ ep_id = endpoint_at(service, rel, line1)
103
+ return [] if ep_id.nil?
104
+ out = []
105
+ @graph.in_edges(ep_id).each do |src, edata|
106
+ next unless edata["kind"] == "http_call"
107
+ src_d = @graph.node(src) || {}
108
+ src_service = src_d["service"]
109
+ call_file = edata["file"] || src_d["file"]
110
+ call_line = edata["line"] || src_d["start_line"] || 1
111
+ abs_p = src_service ? target_path(src_service, call_file) : nil
112
+ next if abs_p.nil?
113
+ out << { path: abs_p, line: [call_line - 1, 0].max, character: 0 }
114
+ end
115
+ dedupe(out.map { |t| to_location(t) })
116
+ end
117
+
118
+ # Markdown describing the cross-service endpoint the call on this line
119
+ # targets; fires ONLY when an http_call edge's call site is exactly on
120
+ # `line0`. nil when there is nothing to show.
121
+ def hover(abs_path, line0, _character = 0)
122
+ loc = locate(abs_path)
123
+ return nil if loc.nil?
124
+ service, rel = loc
125
+ line1 = line0 + 1
126
+ sym_id = covering_symbol(service, rel, line1)
127
+ return nil if sym_id.nil?
128
+ edges = @graph.out(sym_id, ["http_call"]).select { |_t, e| e["line"] == line1 }
129
+ return nil if edges.empty?
130
+ blocks = []
131
+ edges.each do |tgt, edata|
132
+ tnode = @graph.node(tgt) || {}
133
+ case tnode["kind"]
134
+ when "endpoint"
135
+ b = endpoint_hover(tgt, tnode)
136
+ blocks << b if b
137
+ when "service"
138
+ blocks << "**#{edata['verb'] || '?'} #{edata['path'] || '?'}** — " \
139
+ "route not found in `#{tnode['name']}`"
140
+ end
141
+ end
142
+ return nil if blocks.empty?
143
+ { "markdown" => blocks.join("\n\n---\n\n") }
144
+ end
145
+
146
+ # All CROSS-service jump points in a file. Each item: the 0-based source
147
+ # line where the call/publish is, and the target's absolute path +
148
+ # 0-based line in the OTHER service. Same-service targets are excluded —
149
+ # ruby-lsp handles those in-window.
150
+ def file_targets(abs_path)
151
+ loc = locate(abs_path)
152
+ return [] if loc.nil?
153
+ service, rel = loc
154
+ out = []
155
+ @graph.nodes_in_file(service, rel).each do |node_id, d|
156
+ next unless d["kind"] == "symbol"
157
+ @graph.out(node_id, ["http_call", "publishes"]).each do |tgt, edata|
158
+ src_line0 = [(edata["line"] || d["start_line"] || 1) - 1, 0].max
159
+ kind = edata["kind"]
160
+ tnode = @graph.node(tgt) || {}
161
+ if kind == "publishes"
162
+ next unless tnode["kind"] == "topic"
163
+ publish_targets(tgt).each do |t|
164
+ out << target_item(src_line0, t, "publish")
165
+ end
166
+ elsif tnode["kind"] == "endpoint"
167
+ next if tnode["service"] == service # same service -> skip
168
+ t = node_target(tgt)
169
+ out << target_item(src_line0, t, "http") if t
170
+ elsif tnode["kind"] == "service" && tnode["name"] != service
171
+ # service resolved but NO matching route — link to its routes so
172
+ # you can see what it does expose
173
+ rl = service_routes_location(tnode["name"])
174
+ next if rl.nil?
175
+ path, tline = rl
176
+ out << { "line" => src_line0, "path" => path, "target_line" => tline,
177
+ "label" => "#{edata['verb'] || '?'} #{edata['path'] || '?'}" \
178
+ " — route not found in #{tnode['name']}",
179
+ "kind" => "http-unresolved" }
180
+ end
181
+ end
182
+ end
183
+ # dedupe on (source line, target path, target line)
184
+ seen = {}
185
+ out.select do |item|
186
+ key = [item["line"], item["path"], item["target_line"]]
187
+ seen.key?(key) ? false : (seen[key] = true)
188
+ end
189
+ end
190
+
191
+ private
192
+
193
+ def present?(value)
194
+ !value.nil? && value != ""
195
+ end
196
+
197
+ def target_path(service, rel_file)
198
+ root = @roots[service]
199
+ return nil if root.nil? || !present?(rel_file) || rel_file == "?"
200
+ File.join(root, rel_file)
201
+ end
202
+
203
+ # Smallest symbol node (class/module) whose span covers a 1-based line;
204
+ # when the smallest covering node is an endpoint, fall back to the
205
+ # smallest covering *symbol* in the same file.
206
+ def covering_symbol(service, rel_file, line1)
207
+ nid = @graph.covering_symbol(service, rel_file, line1)
208
+ if !nid.nil? && (@graph.node(nid) || {})["kind"] == "symbol"
209
+ return nid
210
+ end
211
+ best_id = nil
212
+ best_span = nil
213
+ @graph.nodes_in_file(service, rel_file).each do |node_id, d|
214
+ next unless d["kind"] == "symbol"
215
+ start_line = d["start_line"]
216
+ next if start_line.nil?
217
+ end_line = d["end_line"] || start_line
218
+ next unless start_line <= line1 && line1 <= end_line
219
+ span = end_line - start_line
220
+ if best_span.nil? || span < best_span
221
+ best_id = node_id
222
+ best_span = span
223
+ end
224
+ end
225
+ best_id
226
+ end
227
+
228
+ # Endpoint node whose route definition is on/near (± 2 lines) a 1-based line.
229
+ def endpoint_at(service, rel_file, line1)
230
+ candidates = []
231
+ @graph.nodes_in_file(service, rel_file).each do |node_id, d|
232
+ next unless d["kind"] == "endpoint"
233
+ eline = d["line"]
234
+ next if eline.nil?
235
+ candidates << [(eline - line1).abs, node_id]
236
+ end
237
+ return nil if candidates.empty?
238
+ nearest = candidates.min
239
+ nearest[0] <= 2 ? nearest[1] : nil
240
+ end
241
+
242
+ # A resolved navigation target: {path, line (0-based), character, label,
243
+ # kind}, or nil when the node can't be materialized to a file position.
244
+ def node_target(node_id)
245
+ return nil unless @graph.has_node?(node_id)
246
+ d = @graph.node(node_id)
247
+ kind = d["kind"]
248
+ service = d["service"]
249
+ rel = d["file"]
250
+ line = d["line"] || d["start_line"]
251
+ return nil unless %w[endpoint symbol].include?(kind) &&
252
+ present?(service) && present?(rel) && line && line != 0
253
+ abs_path = target_path(service, rel)
254
+ return nil if abs_path.nil?
255
+ label = if kind == "endpoint"
256
+ "#{d['verb'] || ''} #{d['path'] || ''} [#{service}]"
257
+ else
258
+ "#{d['name'] || node_id} [#{service}]"
259
+ end
260
+ { path: abs_path, line: [line - 1, 0].max, character: 0,
261
+ label: label, kind: kind }
262
+ end
263
+
264
+ # publish edge -> topic -> queue(s) -> consumer handler(s), plus each
265
+ # handler's target Process.
266
+ def publish_targets(topic_id)
267
+ out = []
268
+ @graph.out(topic_id, ["binds"]).each do |queue_id, _e|
269
+ @graph.out(queue_id, ["consumes"]).each do |consumer_id, _e2|
270
+ t = node_target(consumer_id)
271
+ out << t if t
272
+ @graph.out(consumer_id, ["calls"]).each do |tgt, _e3|
273
+ ht = node_target(tgt)
274
+ out << ht if ht
275
+ end
276
+ end
277
+ end
278
+ out
279
+ end
280
+
281
+ # Topmost route of a service — the landing spot when a call resolved the
282
+ # service but matched no specific route. [abs_path, 0-based line] or nil.
283
+ def service_routes_location(svc_name)
284
+ best = nil
285
+ @graph.endpoints(svc_name).each do |_node_id, d|
286
+ line = d["line"] || 1
287
+ next unless best.nil? || line < best[1]
288
+ abs_path = target_path(svc_name, d["file"])
289
+ best = [abs_path, line] if abs_path
290
+ end
291
+ best && [best[0], [best[1] - 1, 0].max]
292
+ end
293
+
294
+ def endpoint_hover(ep_id, d)
295
+ verb = d["verb"] || "?"
296
+ path = d["path"] || "?"
297
+ service = d["service"] || "?"
298
+ parts = ["**#{verb} #{path}** · _#{service}_"]
299
+ @graph.out(ep_id, ["routes_to"]).each do |tgt, _e|
300
+ hd = @graph.node(tgt) || {}
301
+ parts << "**Handler:** `#{hd['name']}`" if present?(hd["name"])
302
+ doc = hd["call_doc"] || hd["doc"]
303
+ parts << (present?(doc) ? doc : "_No `@return` documented on the handler._")
304
+ break
305
+ end
306
+ parts.join("\n\n")
307
+ end
308
+
309
+ def target_item(src_line0, t, kind)
310
+ { "line" => src_line0, "path" => t[:path], "target_line" => t[:line],
311
+ "label" => t[:label], "kind" => kind }
312
+ end
313
+
314
+ def to_location(t)
315
+ { "path" => t[:path], "line" => t[:line], "character" => t[:character] || 0 }
316
+ end
317
+
318
+ def dedupe(locations)
319
+ seen = {}
320
+ locations.select do |l|
321
+ key = [l["path"], l["line"], l["character"]]
322
+ seen.key?(key) ? false : (seen[key] = true)
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "webrick"
5
+
6
+ module Msnav
7
+ # The msnav daemon: WEBrick serving the coderag daemon's navigation API.
8
+ #
9
+ # The Holder is the single source of index state (graph + navigator),
10
+ # replaced atomically when the shared DB's `index_generation` moves — a
11
+ # host-side `coderag index` or a peer daemon's rebuild hot-reloads every
12
+ # msnav on the hub within a second, requests always see a consistent index.
13
+ class Holder
14
+ attr_reader :cfg, :store, :windows, :service_root, :service_name
15
+
16
+ def initialize(cfg, service_root: nil, service_name: nil)
17
+ @cfg = cfg
18
+ # service scope: this daemon's container sees ONE service's source at
19
+ # this path (msnav never extracts from it — the scope only names which
20
+ # indexed service this window holds, for the extension's path mapping)
21
+ @service_root = service_root
22
+ @service_name = service_name
23
+ @store = Store.new(cfg.db_path)
24
+ @windows = WindowRegistry.new(@store)
25
+ @mutex = Mutex.new
26
+ @generation = 0
27
+ @db_generation = @store.index_generation
28
+ @navigator = load_navigator
29
+ end
30
+
31
+ # A service-scoped daemon's identity: the container path holding the
32
+ # service and its canonical (host) location from the shared DB — the
33
+ # exact path mapping the editor extension needs, with no folder-name
34
+ # guessing (mounts like `/app` carry no service name).
35
+ def scope
36
+ return nil unless @service_root
37
+ base = @service_name ||
38
+ File.basename(File.expand_path(@service_root))
39
+ @store.services.each do |name, dirname, canonical_root|
40
+ next unless dirname == base
41
+ return { "service" => name, "dirname" => dirname,
42
+ "service_root" => @service_root.to_s,
43
+ "root" => canonical_root }
44
+ end
45
+ { "service" => nil, "dirname" => base,
46
+ "service_root" => @service_root.to_s, "root" => nil }
47
+ end
48
+
49
+ def navigator
50
+ @mutex.synchronize { @navigator }
51
+ end
52
+
53
+ def generation
54
+ @mutex.synchronize { @generation }
55
+ end
56
+
57
+ # Follower path: hot-swap the navigator when another daemon (or a host
58
+ # reindex) moved the persisted generation. True when a reload happened.
59
+ def maybe_reload
60
+ gen = @store.index_generation
61
+ return false if gen == @db_generation
62
+ nav = load_navigator
63
+ @mutex.synchronize do
64
+ @navigator = nav
65
+ @generation += 1
66
+ @db_generation = gen
67
+ end
68
+ warn "msnav: index changed in the shared DB — reloaded (generation #{gen})"
69
+ true
70
+ end
71
+
72
+ private
73
+
74
+ def load_navigator
75
+ Navigator.new(Graph.load(@store, @cfg.workspace_root))
76
+ end
77
+ end
78
+
79
+ class Server
80
+ GENERATION_POLL_SECONDS = 1.0
81
+
82
+ # host editor CLI used to open a project folder at a position
83
+ EDITOR_ENV = "CODERAG_EDITOR"
84
+ DEFAULT_EDITOR = "cursor"
85
+
86
+ def initialize(cfg, host: "127.0.0.1", port: 8787,
87
+ service_root: nil, service_name: nil)
88
+ @holder = Holder.new(cfg, service_root: service_root,
89
+ service_name: service_name)
90
+ @host = host
91
+ @port = port
92
+ end
93
+
94
+ def start
95
+ server = WEBrick::HTTPServer.new(
96
+ BindAddress: @host, Port: @port, AccessLog: [],
97
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN))
98
+ server.mount_proc("/") { |req, res| dispatch(req, res) }
99
+
100
+ poller = Thread.new do
101
+ loop do
102
+ sleep GENERATION_POLL_SECONDS
103
+ begin
104
+ @holder.maybe_reload
105
+ rescue StandardError => e # the poller must survive anything
106
+ warn "msnav: reload check failed: #{e.message}"
107
+ end
108
+ end
109
+ end
110
+
111
+ %w[INT TERM].each { |sig| trap(sig) { server.shutdown } }
112
+ puts "msnav daemon on http://#{@host}:#{@port} " \
113
+ "(navigation API for #{@holder.cfg.workspace_root})"
114
+ server.start
115
+ ensure
116
+ poller && poller.kill
117
+ end
118
+
119
+ private
120
+
121
+ ROUTES = {
122
+ ["GET", "/api/health"] => :health,
123
+ ["GET", "/api/nav/definition"] => :nav_definition,
124
+ ["GET", "/api/nav/references"] => :nav_references,
125
+ ["GET", "/api/nav/hover"] => :nav_hover,
126
+ ["GET", "/api/nav/file-targets"] => :nav_file_targets,
127
+ ["POST", "/api/register-window"] => :register_window,
128
+ ["GET", "/api/window-commands"] => :window_commands,
129
+ ["GET", "/api/windows"] => :list_windows,
130
+ ["POST", "/api/open"] => :open_in_editor,
131
+ ["GET", "/api/services"] => :services,
132
+ ["GET", "/api/routes"] => :routes,
133
+ }.freeze
134
+
135
+ def dispatch(req, res)
136
+ handler = ROUTES[[req.request_method, req.path]]
137
+ res["Content-Type"] = "application/json"
138
+ if handler.nil?
139
+ res.status = 404
140
+ res.body = JSON.generate("detail" => "Not Found")
141
+ return
142
+ end
143
+ begin
144
+ res.status = 200
145
+ res.body = JSON.generate(send(handler, req))
146
+ rescue HttpError => e
147
+ res.status = e.status
148
+ res.body = JSON.generate("detail" => e.message)
149
+ rescue StandardError => e
150
+ warn "msnav: #{req.request_method} #{req.path} failed: " \
151
+ "#{e.class}: #{e.message}\n #{e.backtrace.first(5).join("\n ")}"
152
+ res.status = 500
153
+ res.body = JSON.generate("detail" => "#{e.class}: #{e.message}")
154
+ end
155
+ end
156
+
157
+ class HttpError < StandardError
158
+ attr_reader :status
159
+
160
+ def initialize(status, message)
161
+ @status = status
162
+ super(message)
163
+ end
164
+ end
165
+
166
+ def param(req, name)
167
+ value = req.query[name]
168
+ raise HttpError.new(422, "query must include '#{name}'") if value.nil? || value.empty?
169
+ # WEBrick query values are binary-encoded FormData; sqlite3 would bind
170
+ # binary strings as BLOBs (and TEXT comparisons would silently miss),
171
+ # so normalize to a plain UTF-8 String at the boundary
172
+ String.new(value.to_s).force_encoding(Encoding::UTF_8)
173
+ end
174
+
175
+ def body_json(req)
176
+ JSON.parse(req.body || "")
177
+ rescue JSON::ParserError
178
+ raise HttpError.new(422, "body must be a JSON object")
179
+ end
180
+
181
+ # ----------------------------------------------------------------- health
182
+
183
+ def health(_req)
184
+ graph = @holder.navigator.graph
185
+ {
186
+ "ok" => true,
187
+ "generation" => @holder.generation,
188
+ "root" => @holder.cfg.workspace_root.to_s,
189
+ "version" => VERSION,
190
+ "pid" => Process.pid,
191
+ "services" => graph.services.map { |_id, d| d["name"] }.compact.sort,
192
+ # canonical service locations (host paths in the service-scoped flow)
193
+ # — callers use these for coverage checks and host opens
194
+ "service_roots" => graph.services.map { |_id, d| d["root"] }
195
+ .reject { |r| r.nil? || r.empty? }.sort,
196
+ # a scoped daemon's own service: the container path it holds and the
197
+ # canonical root — the extension's exact path mapping
198
+ "scope" => @holder.scope,
199
+ }
200
+ end
201
+
202
+ # ------------------------------------------------------------- navigation
203
+
204
+ def nav_definition(req)
205
+ @holder.navigator.definition(param(req, "file"), param(req, "line").to_i)
206
+ end
207
+
208
+ def nav_references(req)
209
+ @holder.navigator.references(param(req, "file"), param(req, "line").to_i)
210
+ end
211
+
212
+ def nav_hover(req)
213
+ @holder.navigator.hover(param(req, "file"), param(req, "line").to_i) || {}
214
+ end
215
+
216
+ def nav_file_targets(req)
217
+ @holder.navigator.file_targets(param(req, "file"))
218
+ end
219
+
220
+ # ---------------------------------------------------------------- windows
221
+
222
+ def register_window(req)
223
+ payload = body_json(req)
224
+ window_id = payload["window_id"]
225
+ raise HttpError.new(422, "body must include 'window_id'") if window_id.nil? || window_id.empty?
226
+ @holder.windows.register(window_id, payload["authority"],
227
+ payload["roots"], payload["path_mappings"])
228
+ { "registered" => true, "window_id" => window_id,
229
+ "windows" => @holder.windows.count }
230
+ end
231
+
232
+ def window_commands(req)
233
+ window_id = param(req, "window_id")
234
+ if @holder.windows.touch(window_id).nil?
235
+ { "known" => false, "commands" => [] }
236
+ else
237
+ { "known" => true, "commands" => @holder.windows.drain(window_id) }
238
+ end
239
+ end
240
+
241
+ def list_windows(_req)
242
+ now = Time.now.to_f
243
+ @holder.windows.all.map do |r|
244
+ { "window_id" => r.window_id, "authority" => r.authority,
245
+ "roots" => r.roots, "host_roots" => r.host_roots,
246
+ "alive" => r.alive?(now),
247
+ "last_poll_ago" => (now - r.last_poll).round(1) }
248
+ end
249
+ end
250
+
251
+ # Open the target at file:line where it is already open: if a live window
252
+ # owns the target, push an open-command to it; otherwise fall back to a
253
+ # new local host window — or, in a container, hand the open back to the
254
+ # caller (its extension has the one host capability we lack).
255
+ def open_in_editor(req)
256
+ payload = body_json(req)
257
+ file = payload["file"]
258
+ line = (payload["line"] || 1).to_i
259
+ raise HttpError.new(422, "body must include 'file'") if file.nil? || file.empty?
260
+
261
+ reg = @holder.windows.enqueue_open(file, line)
262
+ unless reg.nil?
263
+ return { "opened" => true, "routed" => true, "route" => "pushed",
264
+ "window_id" => reg.window_id }
265
+ end
266
+
267
+ nav = @holder.navigator
268
+ loc = nav.locate(file)
269
+ folder = loc ? nav.roots[loc[0]] : nil
270
+ folder ||= File.dirname(file) # fall back to the file's dir
271
+
272
+ unless host_editor_reachable?
273
+ # in-container daemon with no live window owning the target: hand the
274
+ # SERVICE FOLDER back to the caller (its extension opens it on the
275
+ # host — the one capability we lack) and park the file:line open; the
276
+ # new window's extension claims it when it registers
277
+ @holder.windows.park_open(file, [line, 1].max)
278
+ return { "opened" => false, "routed" => false, "folder" => folder,
279
+ "path" => file, "line" => [line, 1].max }
280
+ end
281
+
282
+ editor = ENV[EDITOR_ENV] || DEFAULT_EDITOR
283
+ launch([editor, folder, "--goto", "#{file}:#{[line, 1].max}:1"])
284
+ { "opened" => true, "routed" => false, "route" => "fallback",
285
+ "folder" => folder, "goto" => "#{file}:#{line}" }
286
+ end
287
+
288
+ # ------------------------------------------------------------ diagnostics
289
+
290
+ def services(_req)
291
+ graph = @holder.navigator.graph
292
+ graph.services.sort_by { |id, _d| id }.map do |_id, svc|
293
+ eps = graph.endpoints(svc["name"])
294
+ .sort_by { |_n, d| [d["path"] || "", d["verb"] || ""] }
295
+ { "name" => svc["name"], "root" => svc["root"],
296
+ "endpoints" => eps.map { |_n, d| { "verb" => d["verb"], "path" => d["path"] } } }
297
+ end
298
+ end
299
+
300
+ def routes(req)
301
+ service = req.query["service"]
302
+ graph = @holder.navigator.graph
303
+ out = []
304
+ graph.services.sort_by { |id, _d| id }.each do |_id, svc|
305
+ next if service && !service.empty? && svc["name"] != service
306
+ eps = graph.endpoints(svc["name"])
307
+ .sort_by { |_n, d| [d["path"] || "", d["verb"] || ""] }
308
+ eps.each do |node_id, ep|
309
+ handlers = graph.out(node_id, ["routes_to"])
310
+ .select { |t, _e| graph.has_node?(t) }
311
+ .map { |t, _e| graph.node(t)["name"] }
312
+ out << { "service" => svc["name"], "verb" => ep["verb"],
313
+ "path" => ep["path"], "handler" => handlers.first }
314
+ end
315
+ end
316
+ out
317
+ end
318
+
319
+ # --------------------------------------------------------------- editors
320
+
321
+ # An in-container daemon cannot launch a host editor — /api/open then
322
+ # hands the target back to the caller instead. Overridable for tests and
323
+ # exotic setups via CODERAG_HOST_EDITOR.
324
+ def host_editor_reachable?
325
+ override = ENV["CODERAG_HOST_EDITOR"]
326
+ unless override.nil?
327
+ return !["0", "false", "no", ""].include?(override)
328
+ end
329
+ !File.exist?("/.dockerenv")
330
+ end
331
+
332
+ # Fire the editor CLI detached; false if the process could not start.
333
+ def launch(cmd)
334
+ pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
335
+ Process.detach(pid)
336
+ true
337
+ rescue SystemCallError
338
+ false
339
+ end
340
+ end
341
+ end