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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ module Msnav
7
+ # The slice of coderag.yml msnav needs: where the workspace is and where the
8
+ # index lives. Resolution mirrors coderag's load_config so both daemons
9
+ # agree on the db path for any given hub:
10
+ #
11
+ # workspace_root: explicit arg > coderag.yml's key > "." (the cwd — the
12
+ # lifecycle commands always run the daemon with cwd=hub)
13
+ # storage_dir: declared (workspace-relative unless absolute)
14
+ # > legacy <workspace>/.coderag when it already holds a db
15
+ # > the package data dir (~/.local/share/coderag/hubs/<hub>)
16
+ #
17
+ # All other coderag.yml keys (models, profiles, …) belong to the indexer and
18
+ # are ignored here — msnav never indexes.
19
+ class Config
20
+ attr_reader :workspace_root, :storage_dir, :config_source
21
+
22
+ def self.load(path = nil, workspace_root: nil)
23
+ candidates = []
24
+ candidates << Pathname.new(path) if path
25
+ env = ENV["CODERAG_CONFIG"]
26
+ candidates << Pathname.new(env) if env && !env.empty?
27
+ candidates << (Pathname.new(workspace_root) + "coderag.yml") if workspace_root
28
+ candidates << Pathname.new("coderag.yml")
29
+
30
+ data = {}
31
+ source = nil
32
+ candidates.each do |cand|
33
+ next unless cand.file?
34
+ begin
35
+ data = YAML.safe_load(cand.read) || {}
36
+ rescue Psych::SyntaxError => e
37
+ raise ConfigError, "#{cand}: invalid YAML — #{e.message}"
38
+ end
39
+ raise ConfigError, "#{cand}: top level must be a mapping" unless data.is_a?(Hash)
40
+ source = cand.expand_path
41
+ break
42
+ end
43
+
44
+ new(data, workspace_root: workspace_root, source: source)
45
+ end
46
+
47
+ def initialize(data, workspace_root: nil, source: nil)
48
+ @config_source = source
49
+ root = workspace_root || data["workspace_root"] || "."
50
+ @workspace_root = resolve(Pathname.new(File.expand_path(root.to_s)))
51
+
52
+ declared = data["storage_dir"]
53
+ if declared
54
+ dir = Pathname.new(declared.to_s.sub(/\A~/) { Dir.home })
55
+ dir = @workspace_root + dir unless dir.absolute?
56
+ @storage_dir = resolve(dir)
57
+ else
58
+ legacy = @workspace_root + ".coderag"
59
+ @storage_dir = if (legacy + "coderag.db").exist?
60
+ legacy
61
+ else
62
+ Datadir.hub_dir(@workspace_root)
63
+ end
64
+ end
65
+ end
66
+
67
+ def db_path
68
+ @storage_dir + "coderag.db"
69
+ end
70
+
71
+ private
72
+
73
+ # Pathname#realpath for what exists (symlink-stable, like Python's
74
+ # Path.resolve()); already-expanded path otherwise.
75
+ def resolve(pathname)
76
+ pathname.exist? ? pathname.realpath : pathname
77
+ end
78
+ end
79
+ end
data/lib/msnav/ctl.rb ADDED
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "pathname"
6
+ require "uri"
7
+ require "yaml"
8
+
9
+ module Msnav
10
+ # Daemon lifecycle control: the logic behind `msnav up/status/down` — a
11
+ # port of coderag/daemon/ctl.py with identical semantics and exit codes,
12
+ # so the editor extension can branch on them the same way.
13
+ module Ctl
14
+ PROBE_TIMEOUT = 2.0 # seconds per health request
15
+ SPAWN_GRACE = 3.0 # keep probing this long after our child dies (bind race)
16
+
17
+ # `up` exit codes (the extension and scripts branch on these)
18
+ EXIT_OK = 0
19
+ EXIT_FAIL = 1 # spawn failed / port held by a foreign process
20
+ EXIT_NO_HUB = 2 # no coderag.yml found — refuse to spawn
21
+ EXIT_WRONG_ROOT = 3 # healthy daemon on the port, but for another hub
22
+
23
+ # A leaked Bundler context (bundle exec / RUBYOPT=-rbundler/setup) makes
24
+ # rubygems binstubs refuse any gem not in the active Gemfile — the daemon
25
+ # never needs Bundler, so these are scrubbed from the child's environment.
26
+ BUNDLER_ENV = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH
27
+ BUNDLER_VERSION BUNDLER_SETUP].freeze
28
+
29
+ Probe = Struct.new(:state, :health, :detail, keyword_init: true) do
30
+ def root
31
+ health && health["root"]
32
+ end
33
+
34
+ def pid
35
+ health && health["pid"]
36
+ end
37
+ end
38
+
39
+ module_function
40
+
41
+ # Nearest ancestor of START (inclusive) containing coderag.yml — the hub
42
+ # root a daemon may be spawned at. nil is the guard against rooting a
43
+ # daemon inside a single service directory.
44
+ def find_hub_root(start)
45
+ cur = Pathname.new(File.expand_path(start))
46
+ cur = cur.realpath if cur.exist?
47
+ loop do
48
+ return cur if (cur + "coderag.yml").file?
49
+ parent = cur.parent
50
+ return nil if parent == cur
51
+ cur = parent
52
+ end
53
+ end
54
+
55
+ # The workspace root a daemon started at HUB will report: coderag.yml's
56
+ # `workspace_root` key resolved against the hub, else the hub itself.
57
+ def expected_workspace_root(hub)
58
+ hub = Pathname.new(hub)
59
+ begin
60
+ data = YAML.safe_load((hub + "coderag.yml").read) || {}
61
+ declared = data.is_a?(Hash) ? data["workspace_root"] : nil
62
+ if declared
63
+ p = Pathname.new(declared.to_s.sub(/\A~/) { Dir.home })
64
+ candidate = p.absolute? ? p : hub + p
65
+ return candidate.exist? ? candidate.realpath : candidate.cleanpath
66
+ end
67
+ rescue StandardError
68
+ # a broken yml fails properly at daemon startup, not here
69
+ end
70
+ hub.exist? ? hub.realpath : hub
71
+ end
72
+
73
+ # One /api/health probe: healthy daemon, some foreign process, or nothing
74
+ # listening.
75
+ def probe_health(url, timeout = PROBE_TIMEOUT)
76
+ uri = URI.parse("#{url}/api/health")
77
+ body = nil
78
+ begin
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.open_timeout = timeout
81
+ http.read_timeout = timeout
82
+ res = http.get(uri.request_uri)
83
+ unless res.is_a?(Net::HTTPSuccess)
84
+ return Probe.new(state: "foreign", health: {},
85
+ detail: "HTTP #{res.code} from #{url}")
86
+ end
87
+ body = res.body
88
+ rescue StandardError => e
89
+ return Probe.new(state: "down", health: {}, detail: e.message)
90
+ end
91
+ health = begin
92
+ JSON.parse(body)
93
+ rescue JSON::ParserError
94
+ return Probe.new(state: "foreign", health: {},
95
+ detail: "non-JSON response from #{url}")
96
+ end
97
+ unless health.is_a?(Hash) && health.key?("ok") && health.key?("root")
98
+ return Probe.new(state: "foreign", health: {},
99
+ detail: "not a coderag daemon at #{url}")
100
+ end
101
+ Probe.new(state: "healthy", health: health, detail: "")
102
+ end
103
+
104
+ def same_root(a, b)
105
+ real(a.to_s) == real(b.to_s)
106
+ end
107
+
108
+ def real(path)
109
+ File.realpath(path)
110
+ rescue SystemCallError
111
+ File.expand_path(path)
112
+ end
113
+
114
+ # Where this hub's index/logs live — mirrors config resolution so the
115
+ # lifecycle commands know the log location before any daemon runs.
116
+ def resolved_storage(hub)
117
+ hub = Pathname.new(hub)
118
+ declared = nil
119
+ begin
120
+ data = YAML.safe_load((hub + "coderag.yml").read) || {}
121
+ declared = data.is_a?(Hash) ? data["storage_dir"] : nil
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ root = expected_workspace_root(hub)
126
+ if declared
127
+ p = Pathname.new(declared.to_s.sub(/\A~/) { Dir.home })
128
+ return p.absolute? ? p : (root + p).cleanpath
129
+ end
130
+ legacy = root + ".coderag"
131
+ return legacy if (legacy + "coderag.db").exist?
132
+ Datadir.hub_dir(root)
133
+ end
134
+
135
+ # Raw stdout/stderr of a spawned daemon. Separate from coderag.log.
136
+ def daemon_log_path(hub)
137
+ resolved_storage(hub) + "daemon.log"
138
+ end
139
+
140
+ # The command that reruns THIS msnav — the gem binstub when we were
141
+ # started through one, else ruby + the packaged exe.
142
+ def self_command
143
+ exe = File.expand_path($PROGRAM_NAME)
144
+ return [exe] if File.file?(exe) && File.executable?(exe)
145
+ require "rbconfig"
146
+ ruby = File.join(RbConfig::CONFIG["bindir"],
147
+ RbConfig::CONFIG["ruby_install_name"])
148
+ [ruby, File.expand_path("../../exe/msnav", __dir__)]
149
+ end
150
+
151
+ # Launch `msnav daemon` detached: new process group (outlives the
152
+ # caller), cwd = hub (so a relative workspace_root resolves there),
153
+ # output appended to daemon.log. Returns [pid, waiter_thread].
154
+ def spawn_daemon(hub, config_path, host, port,
155
+ service_root: nil, service_name: nil, log_path: nil)
156
+ log_path = log_path || daemon_log_path(hub)
157
+ log_path.parent.mkpath
158
+ argv = self_command + ["daemon", "--config", config_path.to_s,
159
+ "--host", host, "--port", port.to_s]
160
+ argv += ["--service-root", service_root.to_s] if service_root
161
+ argv += ["--service", service_name.to_s] if service_name
162
+ log_file = File.open(log_path.to_s, "ab")
163
+ begin
164
+ scrub = BUNDLER_ENV.each_with_object({}) { |k, h| h[k] = nil }
165
+ pid = Process.spawn(scrub, *argv, chdir: hub.to_s, pgroup: true,
166
+ in: File::NULL, out: log_file, err: log_file)
167
+ ensure
168
+ log_file.close # the child holds its own fd
169
+ end
170
+ [pid, Process.detach(pid)]
171
+ end
172
+
173
+ # Poll until something answers health or the deadline passes. A dead
174
+ # child is NOT failure by itself: losing the port-bind race to another
175
+ # window's daemon is success as long as a healthy daemon appears — the
176
+ # caller compares roots. After the child dies we keep probing briefly.
177
+ def wait_healthy(url, waiter, timeout, poll = 0.25)
178
+ deadline = monotonic + timeout
179
+ died_at = nil
180
+ loop do
181
+ probe = probe_health(url, [PROBE_TIMEOUT, poll * 4].min)
182
+ return probe unless probe.state == "down"
183
+ now = monotonic
184
+ died_at = now if waiter && !waiter.alive? && died_at.nil?
185
+ if now >= deadline || (!died_at.nil? && now - died_at > SPAWN_GRACE)
186
+ return probe
187
+ end
188
+ sleep poll
189
+ end
190
+ end
191
+
192
+ def tail_log(log_path, lines = 20)
193
+ content = File.read(log_path.to_s).split("\n")
194
+ content.last(lines).join("\n")
195
+ rescue SystemCallError
196
+ ""
197
+ end
198
+
199
+ def monotonic
200
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "pathname"
5
+ require "sqlite3"
6
+
7
+ module Msnav
8
+ # Package-owned data directory — mirrors coderag/datadir.py exactly, so an
9
+ # index built by `coderag index` on the host is found at the same place
10
+ # inside the container (the one uniform bind mount:
11
+ # ~/.local/share/coderag -> /root/.local/share/coderag).
12
+ module Datadir
13
+ module_function
14
+
15
+ def data_dir
16
+ override = ENV["CODERAG_DATA_DIR"]
17
+ return Pathname.new(File.expand_path(override)) if override && !override.empty?
18
+ xdg = ENV["XDG_DATA_HOME"]
19
+ base = xdg && !xdg.empty? ? Pathname.new(File.expand_path(xdg)) : Pathname.new(Dir.home) + ".local" + "share"
20
+ base + "coderag"
21
+ end
22
+
23
+ # The hub's slice of the data dir: readable name + path-hash suffix.
24
+ # Must match coderag's sha256(str(resolved_root))[:10].
25
+ def hub_dir(workspace_root)
26
+ root = Pathname.new(File.expand_path(workspace_root))
27
+ root = root.realpath if root.exist?
28
+ key = Digest::SHA256.hexdigest(root.to_s)[0, 10]
29
+ data_dir + "hubs" + "#{root.basename}-#{key}"
30
+ end
31
+
32
+ # content matching: enough overlap between the index's cached file paths
33
+ # and the files actually on disk to be sure this folder IS that service
34
+ MATCH_MIN_FILES = 3
35
+ MATCH_MIN_RATIO = 0.6
36
+ SAMPLE_LIMIT = 2000
37
+ SKIP_DIRS = %w[.git vendor node_modules tmp log spec test].freeze
38
+
39
+ # Relative .rb paths under SERVICE_ROOT (bounded walk).
40
+ def disk_ruby_files(service_root)
41
+ found = {}
42
+ queue = [Pathname.new(service_root)]
43
+ root = Pathname.new(service_root)
44
+ until queue.empty?
45
+ dir = queue.shift
46
+ children = begin
47
+ dir.children
48
+ rescue SystemCallError
49
+ next
50
+ end
51
+ children.each do |entry|
52
+ if entry.directory?
53
+ queue << entry unless SKIP_DIRS.include?(entry.basename.to_s)
54
+ elsif entry.extname == ".rb"
55
+ found[entry.relative_path_from(root).to_s.tr(File::SEPARATOR, "/")] = true
56
+ return found if found.length >= SAMPLE_LIMIT
57
+ end
58
+ end
59
+ end
60
+ found
61
+ end
62
+
63
+ # Which indexed service (dirname) does SERVICE_ROOT hold? Directory-name
64
+ # match first; otherwise by content — the service whose cached file paths
65
+ # are actually present on disk (mounts like `/app` don't carry the
66
+ # service name). Mirrors coderag's datadir.identify_service.
67
+ def identify_service(db_path, service_root)
68
+ service_root = Pathname.new(service_root)
69
+ con = begin
70
+ SQLite3::Database.new(db_path.to_s, readonly: true)
71
+ rescue SQLite3::Exception
72
+ return nil
73
+ end
74
+ begin
75
+ dirnames = begin
76
+ con.execute("SELECT dirname FROM services").map { |r| r[0] }
77
+ rescue SQLite3::Exception
78
+ return nil
79
+ end
80
+ base = service_root.basename.to_s
81
+ return base if dirnames.include?(base)
82
+ disk = disk_ruby_files(service_root)
83
+ return nil if disk.empty?
84
+ best = nil
85
+ dirnames.each do |dirname|
86
+ paths = begin
87
+ con.execute("SELECT path FROM facts WHERE service = ?", [dirname])
88
+ .map { |r| r[0] }
89
+ rescue SQLite3::Exception
90
+ return nil
91
+ end
92
+ present = paths.count { |p| disk.key?(p) }
93
+ next if present < MATCH_MIN_FILES || paths.empty?
94
+ ratio = present.to_f / paths.length
95
+ if ratio >= MATCH_MIN_RATIO && (best.nil? || ratio > best[0])
96
+ best = [ratio, dirname]
97
+ end
98
+ end
99
+ best && best[1]
100
+ ensure
101
+ con.close
102
+ end
103
+ end
104
+
105
+ # [hub dir, service dirname] candidates for the service at SERVICE_ROOT —
106
+ # how a service-only container identifies its hub with nothing but the
107
+ # data-dir mount.
108
+ def find_hubs_for_service_root(service_root)
109
+ hubs = data_dir + "hubs"
110
+ return [] unless hubs.directory?
111
+ matches = []
112
+ hubs.children.sort.each do |hub|
113
+ db = hub + "coderag.db"
114
+ next unless db.file?
115
+ dirname = identify_service(db, service_root)
116
+ matches << [hub, dirname] if dirname
117
+ end
118
+ matches
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Msnav
4
+ class Error < StandardError; end
5
+
6
+ # The index is missing, foreign, or from an incompatible coderag schema.
7
+ class IndexStaleError < Error; end
8
+
9
+ # coderag.yml could not be parsed into the keys msnav needs.
10
+ class ConfigError < Error; end
11
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Msnav
6
+ # In-memory view of the coderag code graph (nodes/edges tables), with the
7
+ # same secondary indexes coderag builds: by-kind, per-(service, file) node
8
+ # lists, and per-file line-interval lists for covering-symbol lookup.
9
+ #
10
+ # Node data and edge data are the JSON hashes coderag persisted (string
11
+ # keys); an edge's "kind" column is merged back into its data hash, exactly
12
+ # like CodeGraph.load does in Python.
13
+ class Graph
14
+ attr_reader :nodes
15
+
16
+ # Load the graph and re-anchor service roots when the same workspace is
17
+ # mounted at a different path than at index time (host <-> container).
18
+ # Each root is rebased only if the rebased directory actually exists — a
19
+ # container that mounts only the data dir keeps the stored host paths and
20
+ # serves them verbatim (the service-scoped devcontainer flow).
21
+ def self.load(store, workspace_root = nil)
22
+ nodes = {}
23
+ out_edges = Hash.new { |h, k| h[k] = [] }
24
+ in_edges = Hash.new { |h, k| h[k] = [] }
25
+ stored_ws = nil
26
+ store.with_conn do |db|
27
+ db.execute("SELECT id, data FROM nodes") do |id, data|
28
+ nodes[id] = JSON.parse(data)
29
+ end
30
+ db.execute("SELECT src, dst, kind, data FROM edges") do |src, dst, kind, data|
31
+ edge = JSON.parse(data)
32
+ edge["kind"] = kind
33
+ nodes[src] ||= {} # edges may reference nodes with no attributes
34
+ nodes[dst] ||= {}
35
+ out_edges[src] << [dst, edge]
36
+ in_edges[dst] << [src, edge]
37
+ end
38
+ row = db.get_first_row("SELECT value FROM meta WHERE key='workspace_root'")
39
+ stored_ws = row && row[0]
40
+ end
41
+ graph = new(nodes, out_edges, in_edges)
42
+ graph.rebase_roots(stored_ws, workspace_root.to_s) if workspace_root
43
+ graph
44
+ end
45
+
46
+ def initialize(nodes, out_edges, in_edges)
47
+ @nodes = nodes
48
+ @out_edges = out_edges
49
+ @in_edges = in_edges
50
+ build_indexes
51
+ end
52
+
53
+ def node(node_id)
54
+ @nodes[node_id]
55
+ end
56
+
57
+ def has_node?(node_id)
58
+ @nodes.key?(node_id)
59
+ end
60
+
61
+ # Outgoing [dst, edge_data] pairs, optionally filtered to edge kinds.
62
+ def out(node_id, kinds = nil)
63
+ edges = @out_edges[node_id] || []
64
+ return edges if kinds.nil?
65
+ edges.select { |_dst, data| kinds.include?(data["kind"]) }
66
+ end
67
+
68
+ # Incoming [src, edge_data] pairs (cross-service references).
69
+ def in_edges(node_id)
70
+ @in_edges[node_id] || []
71
+ end
72
+
73
+ def nodes_of_kind(kind)
74
+ (@by_kind[kind] || []).map { |id| [id, @nodes[id]] }
75
+ end
76
+
77
+ def nodes_in_file(service, file)
78
+ (@by_service_file[[service, file]] || []).map { |id| [id, @nodes[id]] }
79
+ end
80
+
81
+ def services
82
+ nodes_of_kind("service")
83
+ end
84
+
85
+ def endpoints(service = nil)
86
+ nodes_of_kind("endpoint").select do |_id, data|
87
+ service.nil? || data["service"] == service
88
+ end
89
+ end
90
+
91
+ # Smallest node whose line span covers `line` (1-based) in the file's
92
+ # interval list. Ties break toward the later start line, matching the
93
+ # Python bisect walk.
94
+ def covering_symbol(service, file, line)
95
+ spans = @intervals[[service, file]] || []
96
+ best = nil
97
+ spans.reverse_each do |start_line, end_line, node_id|
98
+ next if start_line > line
99
+ next if end_line < line
100
+ size = end_line - start_line
101
+ best = [size, node_id] if best.nil? || size < best[0]
102
+ end
103
+ best && best[1]
104
+ end
105
+
106
+ # Re-anchor service roots from the workspace root stored at index time to
107
+ # WORKSPACE_ROOT. No-op when they match, the meta key is missing, or the
108
+ # rebased directory does not exist.
109
+ def rebase_roots(stored_ws, workspace_root)
110
+ ws = File.expand_path(workspace_root)
111
+ return if stored_ws.nil? || stored_ws.empty?
112
+ return if File.expand_path(stored_ws) == ws
113
+ stored = Pathname.new(File.expand_path(stored_ws))
114
+ services.each do |_id, data|
115
+ root = data["root"]
116
+ next if root.nil? || root.empty?
117
+ begin
118
+ rel = Pathname.new(File.expand_path(root)).relative_path_from(stored).to_s
119
+ rescue ArgumentError
120
+ next
121
+ end
122
+ next if rel.start_with?("..")
123
+ candidate = rel == "." ? ws : File.join(ws, rel)
124
+ data["root"] = candidate if File.directory?(candidate)
125
+ end
126
+ build_indexes
127
+ end
128
+
129
+ private
130
+
131
+ def build_indexes
132
+ @by_kind = {}
133
+ @by_service_file = {}
134
+ @intervals = {}
135
+ @nodes.each do |id, data|
136
+ kind = data["kind"]
137
+ (@by_kind[kind] ||= []) << id if kind
138
+ service = data["service"]
139
+ file = data["file"]
140
+ next unless service && file
141
+ key = [service, file]
142
+ (@by_service_file[key] ||= []) << id
143
+ start_line = data["start_line"] || data["line"]
144
+ next if start_line.nil?
145
+ end_line = data["end_line"] || start_line
146
+ (@intervals[key] ||= []) << [start_line, end_line, id]
147
+ end
148
+ @intervals.each_value(&:sort!)
149
+ end
150
+ end
151
+ end