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,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "sqlite3"
5
+
6
+ module Msnav
7
+ # Access to the shared coderag SQLite index.
8
+ #
9
+ # coderag (Python) owns the schema and the writes that matter (graph, facts,
10
+ # chunks); msnav writes only the two v4 message-bus tables (`windows`,
11
+ # `window_commands`) that every daemon on the hub shares. One connection
12
+ # guarded by a Mutex: navigation itself runs on the in-memory graph, so the
13
+ # DB sees only window-registry traffic, generation polls, and graph reloads
14
+ # — serializing those is simpler than per-thread connections and equally
15
+ # correct across WEBrick's request threads.
16
+ class Store
17
+ SCHEMA_VERSION = 4 # the version msnav is written against
18
+
19
+ attr_reader :path
20
+
21
+ def initialize(path)
22
+ @path = Pathname.new(path.to_s)
23
+ unless @path.file?
24
+ raise IndexStaleError,
25
+ "no index at #{@path} — build it on the host with `coderag index` " \
26
+ "and mount the coderag data dir into this container"
27
+ end
28
+ @mutex = Mutex.new
29
+ @conn = connect
30
+ check_version
31
+ end
32
+
33
+ # All DB access funnels through here. Reentrant per call site by design:
34
+ # never call with_conn inside with_conn.
35
+ def with_conn
36
+ @mutex.synchronize { yield @conn }
37
+ end
38
+
39
+ def close
40
+ @mutex.synchronize do
41
+ @conn.close unless @conn.closed?
42
+ end
43
+ end
44
+
45
+ def get_meta(key)
46
+ with_conn do |db|
47
+ row = db.get_first_row("SELECT value FROM meta WHERE key = ?", [key])
48
+ row && row[0]
49
+ end
50
+ end
51
+
52
+ # The shared DB's index generation — bumped by whichever coderag daemon
53
+ # (or host `coderag index`) last completed a build. 0 when unset.
54
+ def index_generation
55
+ (get_meta("index_generation") || 0).to_i
56
+ end
57
+
58
+ # Canonical [name, dirname, root] service rows as indexed on the host.
59
+ def services
60
+ with_conn do |db|
61
+ db.execute("SELECT name, dirname, root FROM services ORDER BY name")
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def connect
68
+ db = SQLite3::Database.new(@path.to_s)
69
+ db.busy_timeout = 5000
70
+ db.execute("PRAGMA foreign_keys=ON")
71
+ db
72
+ rescue SQLite3::Exception => e
73
+ raise IndexStaleError, "#{@path} could not be opened (#{e.message})"
74
+ end
75
+
76
+ def check_version
77
+ row = begin
78
+ @conn.get_first_row("SELECT value FROM meta WHERE key='schema_version'")
79
+ rescue SQLite3::Exception => e
80
+ raise IndexStaleError,
81
+ "#{@path} is not a coderag database (#{e.message}) — " \
82
+ "delete it and run `coderag index` on the host"
83
+ end
84
+ version = row ? row[0].to_i : 0
85
+ if version < SCHEMA_VERSION
86
+ raise IndexStaleError,
87
+ "#{@path} is schema v#{version}, msnav needs v#{SCHEMA_VERSION} — " \
88
+ "run `coderag index` on the host to migrate"
89
+ end
90
+ return if version == SCHEMA_VERSION
91
+ # a newer coderag may have added tables msnav doesn't know; the v4
92
+ # tables it reads are append-only by convention, so proceed loudly
93
+ warn "msnav: #{@path} is schema v#{version} (msnav is written against " \
94
+ "v#{SCHEMA_VERSION}) — continuing, but upgrade msnav if navigation misbehaves"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Msnav
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Msnav
6
+ # DB-backed registry of live editor windows for bridge-mode open routing —
7
+ # the Ruby side of coderag/daemon/windows.py, byte-compatible on the shared
8
+ # `windows` / `window_commands` tables so msnav daemons in Ruby containers
9
+ # and coderag daemons elsewhere route opens to each other's windows.
10
+ #
11
+ # Each window registers and then short-polls for commands. When /api/open
12
+ # targets a file another live window owns, the command is enqueued in the
13
+ # DB (the message bus between containers) and that window's poll delivers
14
+ # it. A window counts as live only while it keeps polling.
15
+ class WindowRegistry
16
+ ALIVE_SECONDS = 6.0 # a registration is live only if it polled this recently
17
+ PURGE_SECONDS = 3600.0 # rows this stale are garbage-collected on registration
18
+ # A parked open-command (no owning window yet — a new window is being
19
+ # opened for it) waits this long for that window's extension to register.
20
+ # Generous: a first-time "Reopen in Container" builds the image first.
21
+ PARK_SECONDS = 600.0
22
+ # window_id marker for parked commands.
23
+ PARKED = ""
24
+
25
+ COLS = "window_id, authority, roots, path_mappings, host_roots, seq, last_poll"
26
+
27
+ WindowReg = Struct.new(:window_id, :authority, :roots, :path_mappings,
28
+ :host_roots, :seq, :last_poll, keyword_init: true) do
29
+ def alive?(now)
30
+ (now - last_poll) <= ALIVE_SECONDS
31
+ end
32
+ end
33
+
34
+ def initialize(store)
35
+ @store = store
36
+ end
37
+
38
+ def count
39
+ @store.with_conn { |db| db.get_first_value("SELECT COUNT(*) FROM windows") }
40
+ end
41
+
42
+ # Record (or refresh) a window. A reload re-registers with a fresh
43
+ # window_id but identical roots — stale entries with the same mapped
44
+ # (daemon-space) roots are dropped so only the live window remains.
45
+ def register(window_id, authority, roots, path_mappings)
46
+ roots = Array(roots)
47
+ pmaps = Array(path_mappings)
48
+ host_roots = roots.map { |r| root_to_host(pmaps, r) }.uniq.sort
49
+ hr_json = JSON.generate(host_roots)
50
+ now = Time.now.to_f
51
+ reg = nil
52
+ @store.with_conn do |db|
53
+ db.transaction(:immediate) do
54
+ db.execute(
55
+ "DELETE FROM window_commands WHERE window_id IN " \
56
+ "(SELECT window_id FROM windows WHERE last_poll < ?)",
57
+ [now - PURGE_SECONDS])
58
+ db.execute("DELETE FROM windows WHERE last_poll < ?",
59
+ [now - PURGE_SECONDS])
60
+ # expired parked commands: the window they waited for never came
61
+ db.execute(
62
+ "DELETE FROM window_commands WHERE window_id = ? AND created_at < ?",
63
+ [PARKED, now - PARK_SECONDS])
64
+ db.execute(
65
+ "DELETE FROM windows WHERE host_roots = ? AND window_id != ?",
66
+ [hr_json, window_id])
67
+ seq = db.get_first_value(
68
+ "SELECT COALESCE(MAX(seq), 0) + 1 FROM windows")
69
+ db.execute(
70
+ "INSERT INTO windows (window_id, authority, roots, " \
71
+ "path_mappings, host_roots, seq, last_poll) " \
72
+ "VALUES (?, ?, ?, ?, ?, ?, ?) " \
73
+ "ON CONFLICT(window_id) DO UPDATE SET authority = excluded.authority, " \
74
+ "roots = excluded.roots, path_mappings = excluded.path_mappings, " \
75
+ "host_roots = excluded.host_roots, seq = excluded.seq, " \
76
+ "last_poll = excluded.last_poll",
77
+ [window_id, authority || "", JSON.generate(roots),
78
+ JSON.generate(pmaps), hr_json, seq, now])
79
+ # hand parked commands to the window that was opened FOR them: the
80
+ # first registration whose roots cover a parked path claims it
81
+ db.execute(
82
+ "SELECT id, payload FROM window_commands " \
83
+ "WHERE window_id = ? ORDER BY id", [PARKED]).each do |cmd_id, payload|
84
+ target = JSON.parse(payload)["path"] || ""
85
+ next unless host_roots.any? { |hr| under?(hr, target) }
86
+ db.execute("UPDATE window_commands SET window_id = ? WHERE id = ?",
87
+ [window_id, cmd_id])
88
+ end
89
+ reg = WindowReg.new(window_id: window_id, authority: authority || "",
90
+ roots: roots, path_mappings: pmaps,
91
+ host_roots: host_roots, seq: seq, last_poll: now)
92
+ end
93
+ end
94
+ reg
95
+ end
96
+
97
+ # Mark a window as still-alive on poll; nil if it isn't registered.
98
+ def touch(window_id)
99
+ @store.with_conn do |db|
100
+ db.execute("UPDATE windows SET last_poll = ? WHERE window_id = ?",
101
+ [Time.now.to_f, window_id])
102
+ return nil if db.changes.zero?
103
+ row = db.get_first_row(
104
+ "SELECT #{COLS} FROM windows WHERE window_id = ?", [window_id])
105
+ row && row_to_reg(row)
106
+ end
107
+ end
108
+
109
+ # Return and clear the window's pending commands — including ones
110
+ # enqueued by a different daemon sharing this DB.
111
+ def drain(window_id)
112
+ rows = nil
113
+ @store.with_conn do |db|
114
+ db.transaction(:immediate) do
115
+ rows = db.execute(
116
+ "SELECT id, payload FROM window_commands " \
117
+ "WHERE window_id = ? ORDER BY id", [window_id])
118
+ unless rows.empty?
119
+ db.execute(
120
+ "DELETE FROM window_commands WHERE id <= ? AND window_id = ?",
121
+ [rows[-1][0], window_id])
122
+ end
123
+ db.execute("UPDATE windows SET last_poll = ? WHERE window_id = ?",
124
+ [Time.now.to_f, window_id])
125
+ end
126
+ end
127
+ rows.map { |_id, payload| JSON.parse(payload) }
128
+ end
129
+
130
+ # Queue an open-command on the live window that owns HOST_PATH; returns
131
+ # the matched registration, or nil when no live window claims it.
132
+ def enqueue_open(host_path, line)
133
+ now = Time.now.to_f
134
+ reg = nil
135
+ @store.with_conn do |db|
136
+ db.transaction(:immediate) do
137
+ rows = db.execute(
138
+ "SELECT #{COLS} FROM windows WHERE last_poll >= ? " \
139
+ "ORDER BY seq DESC", [now - ALIVE_SECONDS])
140
+ rows.each do |row|
141
+ candidate = row_to_reg(row)
142
+ if candidate.host_roots.any? { |hr| under?(hr, host_path) }
143
+ reg = candidate
144
+ break
145
+ end
146
+ end
147
+ unless reg.nil?
148
+ db.execute(
149
+ "INSERT INTO window_commands (window_id, payload, created_at) " \
150
+ "VALUES (?, ?, ?)",
151
+ [reg.window_id,
152
+ JSON.generate("type" => "open", "path" => host_path,
153
+ "line" => line), now])
154
+ end
155
+ end
156
+ end
157
+ reg
158
+ end
159
+
160
+ # Queue an open for a window that does not exist yet: the caller is
161
+ # opening a new editor window for this service, and its extension will
162
+ # claim the command when it registers.
163
+ def park_open(host_path, line)
164
+ now = Time.now.to_f
165
+ @store.with_conn do |db|
166
+ db.transaction(:immediate) do
167
+ # re-clicking must not stack duplicate opens for the same file
168
+ db.execute(
169
+ "SELECT id, payload FROM window_commands WHERE window_id = ?",
170
+ [PARKED]).each do |cmd_id, payload|
171
+ next unless JSON.parse(payload)["path"] == host_path
172
+ db.execute("DELETE FROM window_commands WHERE id = ?", [cmd_id])
173
+ end
174
+ db.execute(
175
+ "INSERT INTO window_commands (window_id, payload, created_at) " \
176
+ "VALUES (?, ?, ?)",
177
+ [PARKED, JSON.generate("type" => "open", "path" => host_path,
178
+ "line" => line), now])
179
+ end
180
+ end
181
+ nil
182
+ end
183
+
184
+ def all
185
+ rows = @store.with_conn do |db|
186
+ db.execute("SELECT #{COLS} FROM windows ORDER BY seq")
187
+ end
188
+ rows.map { |r| row_to_reg(r) }
189
+ end
190
+
191
+ private
192
+
193
+ # True if PATH is ROOT itself or a descendant of it.
194
+ def under?(root, path)
195
+ root = root.sub(%r{/+\z}, "")
196
+ root = "/" if root.empty?
197
+ path == root || path.start_with?(root + "/")
198
+ end
199
+
200
+ # Map a window-space ROOT to daemon-space via {from: window, to: daemon}.
201
+ # In the per-container flow both sides see the hub at the same mount path,
202
+ # so the mappings are typically empty and this is the identity.
203
+ def root_to_host(path_mappings, root)
204
+ path_mappings.each do |m|
205
+ frm = m["from"]
206
+ to = m["to"]
207
+ next unless frm && !frm.empty? && root.start_with?(frm)
208
+ return to.to_s + root[frm.length..-1]
209
+ end
210
+ root
211
+ end
212
+
213
+ def row_to_reg(row)
214
+ WindowReg.new(window_id: row[0], authority: row[1],
215
+ roots: JSON.parse(row[2]),
216
+ path_mappings: JSON.parse(row[3]),
217
+ host_roots: JSON.parse(row[4]),
218
+ seq: row[5], last_poll: row[6])
219
+ end
220
+ end
221
+ end
data/lib/msnav.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "msnav/version"
4
+ require "msnav/errors"
5
+ require "msnav/datadir"
6
+ require "msnav/config"
7
+ require "msnav/store"
8
+ require "msnav/graph"
9
+ require "msnav/navigator"
10
+ require "msnav/windows"
11
+ require "msnav/server"
12
+ require "msnav/ctl"
13
+ require "msnav/cli"
14
+
15
+ # msnav — the Ruby "navigation face" of a coderag index.
16
+ #
17
+ # coderag (Python) builds the index on the host: one SQLite file holding the
18
+ # cross-service graph. msnav reads that same file — typically through the
19
+ # devcontainer's single data-dir bind mount — and serves the daemon endpoints
20
+ # the coderag/msnav editor extension talks to. It never indexes; when a host
21
+ # reindex bumps `index_generation` in the shared DB, msnav hot-reloads.
22
+ module Msnav
23
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: msnav
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Yaroslav Zahoruiko
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-07-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sqlite3
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '1.3'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3'
33
+ - !ruby/object:Gem::Dependency
34
+ name: webrick
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.6'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.6'
47
+ description: |
48
+ A pure-Ruby daemon (ruby-lsp style) serving the coderag navigation API —
49
+ cross-service go-to-definition, references, hover, and CodeLens targets —
50
+ from a coderag SQLite index. Built to run inside a Ruby devcontainer with
51
+ no Python: the host builds the index with coderag, the container mounts
52
+ the shared data dir and runs `msnav up`.
53
+ email:
54
+ - zahoruiko.yaroslav@gmail.com
55
+ executables:
56
+ - msnav
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - LICENSE
61
+ - README.md
62
+ - exe/msnav
63
+ - lib/msnav.rb
64
+ - lib/msnav/cli.rb
65
+ - lib/msnav/config.rb
66
+ - lib/msnav/ctl.rb
67
+ - lib/msnav/datadir.rb
68
+ - lib/msnav/errors.rb
69
+ - lib/msnav/graph.rb
70
+ - lib/msnav/navigator.rb
71
+ - lib/msnav/server.rb
72
+ - lib/msnav/store.rb
73
+ - lib/msnav/version.rb
74
+ - lib/msnav/windows.rb
75
+ homepage: https://github.com/YaroslavZahoruiko/msnav
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ homepage_uri: https://github.com/YaroslavZahoruiko/msnav
80
+ source_code_uri: https://github.com/YaroslavZahoruiko/msnav
81
+ bug_tracker_uri: https://github.com/YaroslavZahoruiko/msnav/issues
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '2.6'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.0.3.1
98
+ signing_key:
99
+ specification_version: 4
100
+ summary: Cross-service Ruby navigation server over a coderag index
101
+ test_files: []