msnav — microservice navigation server (Ruby)

A pure-Ruby daemon, run the way ruby-lsp is run, that serves coderag's cross-service navigation API inside a Ruby devcontainer with no Python. The host builds the index with coderag; the container mounts the shared data dir and runs msnav — same endpoints, same DB, same editor extension UX.

HOST                                      CONTAINER (ruby image, no Python)
─────────────────────────────            ──────────────────────────────────
coderag index <hub>            ──DB──▶   msnav daemon  ◀── editor extension
  ~/.local/share/coderag/hubs/<hub>/       reads coderag.db through the
  (coderag.db stays here)                  ~/.local/share/coderag bind mount

msnav is a navigation face over the shared index: it never indexes. When anything (a host coderag index, a coderag daemon elsewhere) bumps the DB's index_generation, every msnav on the hub hot-reloads within a second.

What it serves

The exact endpoints the editor extension calls, wire-compatible with the Python daemon (verified response-for-response against it — see Tests):

Endpoint Purpose
GET /api/health identity probe: root, services, service_roots, pid
GET /api/nav/definition cross-service go-to-definition (HTTP call → route, publish → consumer, route → handler)
GET /api/nav/references cross-service callers of the endpoint under the cursor
GET /api/nav/hover target endpoint's YARD doc for the call on this line
GET /api/nav/file-targets every cross-service jump point in a file (CodeLens)
POST /api/register-window GET /api/window-commands GET /api/windows POST /api/open bridge-mode window registry + open routing, shared with coderag daemons through the DB
GET /api/services GET /api/routes diagnostics

Not included (by design — they need the indexer/LLM stack): reindex, watch, search, ask/chat, MCP, the composite LSP. Indexing freshness comes from the host.

Quickstart

On the host (once per hub, with coderag installed):

cd /path/to/workspace   # the folder containing all services, with coderag.yml
coderag index . --no-embed

In the devcontainer (Ruby image):

gem install msnav       # or from this repo: gem build msnav.gemspec && gem install msnav-*.gem
msnav up                # idempotent; the editor extension runs this itself
msnav status

The only container config needed is the data-dir mount — one line, identical for every service of every hub:

"mounts": [
  "source=${localEnv:HOME}/.local/share/coderag,target=/root/.local/share/coderag,type=bind"
]

msnav up finds the hub the same way coderag up does: nearest ancestor coderag.yml → the conventional hub mount ($CODERAG_HUB, default /coderag-hub) → the data dir, matching the folder against each hub's index by directory name and then by file contents — so compose-style anonymous mounts (workspaceFolder: /app) resolve without any settings. The resolved service travels to the daemon, whose /api/health reports the scope (container path ↔ canonical host root) that the extension uses as its exact path mapping. Same exit codes too: 0 healthy, 1 failure, 2 no hub, 3 the port serves a different hub. $CODERAG_DATA_DIR / $CODERAG_CONFIG are honored. msnav doctor explains resolution from any folder.

Because service locations come straight from the shared DB, an in-container msnav serves host paths for cross-service targets — jumps land in the window that owns the service (routed via the DB-backed window registry, so it works across per-container daemons, Ruby and Python alike) or open a new host window.

Editor extension

editor/msnav/ is the coderag extension adapted for msnav (msnav.* settings, default daemon command msnav up, provider + bridge modes). Package with npx @vscode/vsce package --no-dependencies. The original coderag extension also works against msnav — the protocol is identical — if you point its coderag.daemonCommand at ["msnav", "up"].

Devcontainer example

examples/devcontainer/ has the complete per-service pattern: Ruby-only Dockerfile (libsqlite3-dev for the sqlite3 gem, no Python), the single data-dir mount, and a postCreate that installs msnav and the extension.

CLI

msnav daemon [--config PATH] [--host H] [--port P]   # run in the foreground
msnav up     [ROOT] [--port P] [--timeout S]         # ensure one is running (idempotent)
msnav status [--port P]                              # health + registered windows
msnav down   [--port P]                              # SIGTERM by health-reported pid

daemon/up also accept --service-root/--service/--no-embed/--no-watch for drop-in compatibility with coderag up command lines; they change nothing (msnav never indexes).

Compatibility

  • Opens coderag.db strictly read-only. Compatibility is checked via the index's read_contract meta key (accepts 1.x) when present; older indexes are accepted from schema v4 up.
  • The editor-window registry and open-command queue are msnav-owned (coderag dropped this responsibility entirely): a sidecar msnav-windows.db next to the index in the hub's storage dir, shared through the same bind mount, so cross-container open-routing between msnav daemons keeps working. msnav versions it itself (PRAGMA user_version). It is ephemeral liveness state; deleting it is safe.
  • Ruby ≥ 2.6; gems: sqlite3, webrick.
  • The Docker-Desktop caveat from coderag applies here too: SQLite (WAL) over bind mounts with several concurrent writers is the sensitive spot; msnav writes only to its small sidecar, never to the index.

Troubleshooting

msnav up exits 2 / "no hub found" in a devcontainer — hub resolution failed. Run msnav doctor in the container terminal (from the workspace folder): it walks the same ladder up uses and shows what each step found. The folder is matched against each hub's index first by directory name, then by content (which service's indexed file paths are actually on disk — ≥3 files and ≥60% present), so anonymous mounts like /app resolve too. Common causes when it still fails:

  1. Hub not indexed / stale — run coderag index on the HOST at the hub (the folder containing all services); content matching compares against that index.
  2. Data dir not visible — the mount targets /root/... but the container runs as a non-root remoteUser, so msnav looks in /home/<user>/.... Mount into the remote user's home, or mount anywhere and set $CODERAG_DATA_DIR to the target.
  3. Escape hatches: $CODERAG_HUB=<hub dir> or --config <hub-dir>/coderag.yml in msnav.daemonCommand.

can't find executable msnav for gem msnav. msnav is not currently included in the bundle — msnav was invoked inside a Bundler context (bundle exec, or an environment exporting RUBYOPT=-rbundler/setup / BUNDLE_GEMFILE): rubygems binstubs then refuse any gem the active Gemfile doesn't list. msnav never needs Bundler. Fixes, best first:

  1. Invoke it with the Bundler context stripped — env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE -u BUNDLE_BIN_PATH msnav up (the editor extension and examples/devcontainer/postCreate.sh already do this, and msnav up scrubs the same variables when spawning the daemon).
  2. Or add gem "msnav", group: :development to the service's Gemfile so the bundle knows it — the ruby-lsp way; only needed if you insist on running it through Bundler.

can't find gem msnav (>= 0.a) with executable msnav (Gem::GemNotFoundException) — msnav was installed as a git gem in a Gemfile (gem "msnav", github: …). Git gems are visible only inside bundle exec for that Gemfile; the binstub Bundler leaves on PATH cannot activate them, so the extension's msnav up fails. msnav never loads your app's code (unlike ruby-lsp), so don't put it in a Gemfile at all — install it as a plain gem in the image (gem install msnav, or build+install from a git checkout). Quick fix inside a running container: cd /usr/local/bundle/bundler/gems/msnav-*/ && gem build msnav.gemspec -o /tmp/m.gem && gem install /tmp/m.gem.

WARN: Unresolved or ambiguous specs during Gem::Specification.reset: stringio (…) during install — benign RubyGems noise, not an msnav error. It appears when the image carries two versions of a default gem (the one bundled with its Ruby plus a newer one some earlier gem install pulled in); any gem command then prints it. Install and runtime are unaffected — verified with the exact duplicate pair on Ruby 3.1. Silence it by upgrading RubyGems in the image (gem update --system) or just ignore it.

Tests

rake test        # or: ruby -Ilib -Itest test/test_*.rb

Unit tests run against a generated schema-v4 fixture DB; the HTTP layer is tested through a real WEBrick boot. Parity was verified against the Python daemon on coderag's examples/shop: 776/776 identical responses across every line of every file for definition / references / hover / file-targets (+ services and routes), plus cross-daemon window routing (register with the Python daemon, open via msnav, and vice versa) and generation hot-reload after a Python-side reindex.