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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +187 -0
- data/exe/msnav +11 -0
- data/lib/msnav/cli.rb +388 -0
- data/lib/msnav/config.rb +79 -0
- data/lib/msnav/ctl.rb +203 -0
- data/lib/msnav/datadir.rb +121 -0
- data/lib/msnav/errors.rb +11 -0
- data/lib/msnav/graph.rb +151 -0
- data/lib/msnav/navigator.rb +326 -0
- data/lib/msnav/server.rb +341 -0
- data/lib/msnav/store.rb +97 -0
- data/lib/msnav/version.rb +5 -0
- data/lib/msnav/windows.rb +221 -0
- data/lib/msnav.rb +23 -0
- metadata +101 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3ea0f1f4fee68dd182f4e86da45adb998043e96a62d79effa4a05094ab8fdbed
|
|
4
|
+
data.tar.gz: 75cd16332b3edc2b185adcbd155dcc4fc88e3904a5887065d3d910edaaccea64
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 87694f9c0587ea8bea74c54027101d26f90c7c42e06790e853388a8608562a1dfadee1736d15fb272aa0f8f86fead6e45afc6684825d47fca1912b3f5c255c9d
|
|
7
|
+
data.tar.gz: 9f1c22c8076ccf207e700b9378ebeb97ee2f4d6c1e6fccde3c6d2f37b23f670edf8aa3164a8c48d038a8f7f5c0629e7d37f0c5cea097956712416cbfb1ad13dd
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yaroslav Zahoruiko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# msnav — microservice navigation server (Ruby)
|
|
2
|
+
|
|
3
|
+
A pure-Ruby daemon, run the way ruby-lsp is run, that serves
|
|
4
|
+
[coderag](../graph-rag-v3)'s cross-service navigation API **inside a Ruby
|
|
5
|
+
devcontainer with no Python**. The host builds the index with coderag; the
|
|
6
|
+
container mounts the shared data dir and runs `msnav` — same endpoints, same
|
|
7
|
+
DB, same editor extension UX.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
HOST CONTAINER (ruby image, no Python)
|
|
11
|
+
───────────────────────────── ──────────────────────────────────
|
|
12
|
+
coderag index <hub> ──DB──▶ msnav daemon ◀── editor extension
|
|
13
|
+
~/.local/share/coderag/hubs/<hub>/ reads coderag.db through the
|
|
14
|
+
(coderag.db stays here) ~/.local/share/coderag bind mount
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
msnav is a **navigation face over the shared index**: it never indexes. When
|
|
18
|
+
anything (a host `coderag index`, a coderag daemon elsewhere) bumps the DB's
|
|
19
|
+
`index_generation`, every msnav on the hub hot-reloads within a second.
|
|
20
|
+
|
|
21
|
+
## What it serves
|
|
22
|
+
|
|
23
|
+
The exact endpoints the editor extension calls, wire-compatible with the
|
|
24
|
+
Python daemon (verified response-for-response against it — see Tests):
|
|
25
|
+
|
|
26
|
+
| Endpoint | Purpose |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `GET /api/health` | identity probe: root, services, service_roots, pid |
|
|
29
|
+
| `GET /api/nav/definition` | cross-service go-to-definition (HTTP call → route, publish → consumer, route → handler) |
|
|
30
|
+
| `GET /api/nav/references` | cross-service callers of the endpoint under the cursor |
|
|
31
|
+
| `GET /api/nav/hover` | target endpoint's YARD doc for the call on this line |
|
|
32
|
+
| `GET /api/nav/file-targets` | every cross-service jump point in a file (CodeLens) |
|
|
33
|
+
| `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 |
|
|
34
|
+
| `GET /api/services` `GET /api/routes` | diagnostics |
|
|
35
|
+
|
|
36
|
+
Not included (by design — they need the indexer/LLM stack): reindex, watch,
|
|
37
|
+
search, ask/chat, MCP, the composite LSP. Indexing freshness comes from the
|
|
38
|
+
host.
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
On the **host** (once per hub, with coderag installed):
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
cd /path/to/workspace # the folder containing all services, with coderag.yml
|
|
46
|
+
coderag index . --no-embed
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
In the **devcontainer** (Ruby image):
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
gem install msnav # or from this repo: gem build msnav.gemspec && gem install msnav-*.gem
|
|
53
|
+
msnav up # idempotent; the editor extension runs this itself
|
|
54
|
+
msnav status
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The only container config needed is the data-dir mount — one line, identical
|
|
58
|
+
for every service of every hub:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
"mounts": [
|
|
62
|
+
"source=${localEnv:HOME}/.local/share/coderag,target=/root/.local/share/coderag,type=bind"
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`msnav up` finds the hub the same way `coderag up` does: nearest ancestor
|
|
67
|
+
`coderag.yml` → the conventional hub mount (`$CODERAG_HUB`, default
|
|
68
|
+
`/coderag-hub`) → the data dir, matching the folder against each hub's index
|
|
69
|
+
by directory name and then **by file contents** — so compose-style anonymous
|
|
70
|
+
mounts (`workspaceFolder: /app`) resolve without any settings. The resolved
|
|
71
|
+
service travels to the daemon, whose `/api/health` reports the `scope`
|
|
72
|
+
(container path ↔ canonical host root) that the extension uses as its exact
|
|
73
|
+
path mapping. Same exit codes too: 0 healthy, 1 failure, 2 no hub, 3 the
|
|
74
|
+
port serves a different hub. `$CODERAG_DATA_DIR` / `$CODERAG_CONFIG` are
|
|
75
|
+
honored. `msnav doctor` explains resolution from any folder.
|
|
76
|
+
|
|
77
|
+
Because service locations come straight from the shared DB, an in-container
|
|
78
|
+
msnav serves **host paths** for cross-service targets — jumps land in the
|
|
79
|
+
window that owns the service (routed via the DB-backed window registry, so
|
|
80
|
+
it works across per-container daemons, Ruby and Python alike) or open a new
|
|
81
|
+
host window.
|
|
82
|
+
|
|
83
|
+
## Editor extension
|
|
84
|
+
|
|
85
|
+
[editor/msnav/](editor/msnav/) is the coderag extension adapted for msnav
|
|
86
|
+
(`msnav.*` settings, default daemon command `msnav up`, provider + bridge
|
|
87
|
+
modes). Package with `npx @vscode/vsce package --no-dependencies`. The
|
|
88
|
+
original coderag extension also works against msnav — the protocol is
|
|
89
|
+
identical — if you point its `coderag.daemonCommand` at `["msnav", "up"]`.
|
|
90
|
+
|
|
91
|
+
## Devcontainer example
|
|
92
|
+
|
|
93
|
+
[examples/devcontainer/](examples/devcontainer/) has the complete per-service
|
|
94
|
+
pattern: Ruby-only Dockerfile (`libsqlite3-dev` for the sqlite3 gem, no
|
|
95
|
+
Python), the single data-dir mount, and a postCreate that installs msnav and
|
|
96
|
+
the extension.
|
|
97
|
+
|
|
98
|
+
## CLI
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
msnav daemon [--config PATH] [--host H] [--port P] # run in the foreground
|
|
102
|
+
msnav up [ROOT] [--port P] [--timeout S] # ensure one is running (idempotent)
|
|
103
|
+
msnav status [--port P] # health + registered windows
|
|
104
|
+
msnav down [--port P] # SIGTERM by health-reported pid
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`daemon`/`up` also accept `--service-root/--service/--no-embed/--no-watch`
|
|
108
|
+
for drop-in compatibility with `coderag up` command lines; they change
|
|
109
|
+
nothing (msnav never indexes).
|
|
110
|
+
|
|
111
|
+
## Compatibility
|
|
112
|
+
|
|
113
|
+
- Reads coderag **schema v4** DBs (errors on older; warns-and-continues on
|
|
114
|
+
newer). Writes only the two v4 message-bus tables (`windows`,
|
|
115
|
+
`window_commands`) every daemon on the hub shares.
|
|
116
|
+
- Ruby ≥ 2.6; gems: `sqlite3`, `webrick`.
|
|
117
|
+
- The Docker-Desktop caveat from coderag applies here too: SQLite (WAL) over
|
|
118
|
+
bind mounts with several concurrent writers is the sensitive spot; msnav
|
|
119
|
+
keeps its writes tiny (window registry only).
|
|
120
|
+
|
|
121
|
+
## Troubleshooting
|
|
122
|
+
|
|
123
|
+
**`msnav up` exits 2 / "no hub found" in a devcontainer** — hub resolution
|
|
124
|
+
failed. Run `msnav doctor` in the container terminal (from the workspace
|
|
125
|
+
folder): it walks the same ladder `up` uses and shows what each step found.
|
|
126
|
+
The folder is matched against each hub's index first by directory name, then
|
|
127
|
+
**by content** (which service's indexed file paths are actually on disk —
|
|
128
|
+
≥3 files and ≥60% present), so anonymous mounts like `/app` resolve too.
|
|
129
|
+
Common causes when it still fails:
|
|
130
|
+
|
|
131
|
+
1. *Hub not indexed / stale* — run `coderag index` on the HOST at the hub
|
|
132
|
+
(the folder containing all services); content matching compares against
|
|
133
|
+
that index.
|
|
134
|
+
2. *Data dir not visible* — the mount targets `/root/...` but the container
|
|
135
|
+
runs as a non-root `remoteUser`, so msnav looks in `/home/<user>/...`.
|
|
136
|
+
Mount into the remote user's home, or mount anywhere and set
|
|
137
|
+
`$CODERAG_DATA_DIR` to the target.
|
|
138
|
+
3. Escape hatches: `$CODERAG_HUB=<hub dir>` or
|
|
139
|
+
`--config <hub-dir>/coderag.yml` in `msnav.daemonCommand`.
|
|
140
|
+
|
|
141
|
+
**`can't find executable msnav for gem msnav. msnav is not currently
|
|
142
|
+
included in the bundle`** — msnav was invoked inside a Bundler context
|
|
143
|
+
(`bundle exec`, or an environment exporting `RUBYOPT=-rbundler/setup` /
|
|
144
|
+
`BUNDLE_GEMFILE`): rubygems binstubs then refuse any gem the active Gemfile
|
|
145
|
+
doesn't list. msnav never needs Bundler. Fixes, best first:
|
|
146
|
+
|
|
147
|
+
1. Invoke it with the Bundler context stripped —
|
|
148
|
+
`env -u RUBYOPT -u RUBYLIB -u BUNDLE_GEMFILE -u BUNDLE_BIN_PATH msnav up`
|
|
149
|
+
(the editor extension and `examples/devcontainer/postCreate.sh` already
|
|
150
|
+
do this, and `msnav up` scrubs the same variables when spawning the
|
|
151
|
+
daemon).
|
|
152
|
+
2. Or add `gem "msnav", group: :development` to the service's Gemfile so the
|
|
153
|
+
bundle knows it — the ruby-lsp way; only needed if you insist on running
|
|
154
|
+
it through Bundler.
|
|
155
|
+
|
|
156
|
+
**`can't find gem msnav (>= 0.a) with executable msnav
|
|
157
|
+
(Gem::GemNotFoundException)`** — msnav was installed as a **git gem** in a
|
|
158
|
+
Gemfile (`gem "msnav", github: …`). Git gems are visible only inside
|
|
159
|
+
`bundle exec` for that Gemfile; the binstub Bundler leaves on PATH cannot
|
|
160
|
+
activate them, so the extension's `msnav up` fails. msnav never loads your
|
|
161
|
+
app's code (unlike ruby-lsp), so don't put it in a Gemfile at all — install
|
|
162
|
+
it as a plain gem in the image (`gem install msnav`, or build+install from a
|
|
163
|
+
git checkout). Quick fix inside a running container:
|
|
164
|
+
`cd /usr/local/bundle/bundler/gems/msnav-*/ && gem build msnav.gemspec -o
|
|
165
|
+
/tmp/m.gem && gem install /tmp/m.gem`.
|
|
166
|
+
|
|
167
|
+
**`WARN: Unresolved or ambiguous specs during Gem::Specification.reset:
|
|
168
|
+
stringio (…)` during install** — benign RubyGems noise, not an msnav error.
|
|
169
|
+
It appears when the image carries two versions of a default gem (the one
|
|
170
|
+
bundled with its Ruby plus a newer one some earlier `gem install` pulled in);
|
|
171
|
+
any `gem` command then prints it. Install and runtime are unaffected —
|
|
172
|
+
verified with the exact duplicate pair on Ruby 3.1. Silence it by upgrading
|
|
173
|
+
RubyGems in the image (`gem update --system`) or just ignore it.
|
|
174
|
+
|
|
175
|
+
## Tests
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
rake test # or: ruby -Ilib -Itest test/test_*.rb
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Unit tests run against a generated schema-v4 fixture DB; the HTTP layer is
|
|
182
|
+
tested through a real WEBrick boot. Parity was verified against the Python
|
|
183
|
+
daemon on coderag's `examples/shop`: **776/776** identical responses across
|
|
184
|
+
every line of every file for definition / references / hover / file-targets
|
|
185
|
+
(+ services and routes), plus cross-daemon window routing (register with the
|
|
186
|
+
Python daemon, open via msnav, and vice versa) and generation hot-reload
|
|
187
|
+
after a Python-side reindex.
|
data/exe/msnav
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# run-from-checkout support: prefer the sibling lib when it's there (a gem
|
|
5
|
+
# install resolves msnav through RubyGems instead)
|
|
6
|
+
lib = File.expand_path("../lib", __dir__)
|
|
7
|
+
$LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
|
|
8
|
+
|
|
9
|
+
require "msnav"
|
|
10
|
+
|
|
11
|
+
exit Msnav::CLI.run(ARGV)
|
data/lib/msnav/cli.rb
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Msnav
|
|
7
|
+
# `msnav` command line: daemon / up / status / down — the same lifecycle
|
|
8
|
+
# surface (flags, hub resolution, exit codes) as `coderag daemon/up/...`,
|
|
9
|
+
# so the editor extension's daemonCommand can point at either.
|
|
10
|
+
module CLI
|
|
11
|
+
USAGE = <<~TEXT
|
|
12
|
+
msnav — cross-service Ruby navigation server over a coderag index
|
|
13
|
+
|
|
14
|
+
Usage: msnav COMMAND [options]
|
|
15
|
+
|
|
16
|
+
Commands:
|
|
17
|
+
daemon Run the navigation daemon (HTTP API on --host/--port)
|
|
18
|
+
up Ensure a daemon for this hub is running (idempotent)
|
|
19
|
+
status Health and registered windows of the daemon
|
|
20
|
+
down Stop the daemon on --host/--port
|
|
21
|
+
doctor Explain hub resolution from the current directory
|
|
22
|
+
version Print the msnav version
|
|
23
|
+
|
|
24
|
+
The index is built on the host by coderag (`coderag index`); msnav
|
|
25
|
+
reads the shared coderag.db (typically via the devcontainer's
|
|
26
|
+
~/.local/share/coderag bind mount) and serves the navigation API.
|
|
27
|
+
TEXT
|
|
28
|
+
|
|
29
|
+
class Exit < StandardError
|
|
30
|
+
attr_reader :code
|
|
31
|
+
|
|
32
|
+
def initialize(code, message = nil)
|
|
33
|
+
@code = code
|
|
34
|
+
super(message || "")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def run(argv)
|
|
41
|
+
command = argv.first
|
|
42
|
+
rest = argv[1..-1] || []
|
|
43
|
+
case command
|
|
44
|
+
when "daemon" then cmd_daemon(rest)
|
|
45
|
+
when "up" then cmd_up(rest)
|
|
46
|
+
when "status" then cmd_status(rest)
|
|
47
|
+
when "down" then cmd_down(rest)
|
|
48
|
+
when "doctor" then cmd_doctor(rest)
|
|
49
|
+
when "version", "--version", "-v"
|
|
50
|
+
puts "msnav #{VERSION}"
|
|
51
|
+
0
|
|
52
|
+
when nil, "help", "--help", "-h"
|
|
53
|
+
puts USAGE
|
|
54
|
+
command.nil? ? 1 : 0
|
|
55
|
+
else
|
|
56
|
+
warn "unknown command: #{command}\n\n#{USAGE}"
|
|
57
|
+
1
|
|
58
|
+
end
|
|
59
|
+
rescue Exit => e
|
|
60
|
+
warn e.message unless e.message.empty?
|
|
61
|
+
e.code
|
|
62
|
+
rescue Error => e
|
|
63
|
+
warn "msnav: #{e.message}"
|
|
64
|
+
1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# -------------------------------------------------------------- commands
|
|
68
|
+
|
|
69
|
+
def cmd_daemon(argv)
|
|
70
|
+
opts = { host: "127.0.0.1", port: 8787 }
|
|
71
|
+
parser = OptionParser.new do |o|
|
|
72
|
+
o.banner = "Usage: msnav daemon [options]"
|
|
73
|
+
o.on("--config PATH", "coderag.yml to load") { |v| opts[:config] = v }
|
|
74
|
+
o.on("--host HOST") { |v| opts[:host] = v }
|
|
75
|
+
o.on("--port PORT", Integer) { |v| opts[:port] = v }
|
|
76
|
+
# service scope: names which indexed service this container's window
|
|
77
|
+
# holds (msnav never extracts — this feeds health's `scope`, the
|
|
78
|
+
# extension's exact container<->host path mapping)
|
|
79
|
+
o.on("--service-root DIR") { |v| opts[:service_root] = v }
|
|
80
|
+
o.on("--service NAME") { |v| opts[:service] = v }
|
|
81
|
+
# accepted for coderag CLI compatibility (msnav never indexes)
|
|
82
|
+
o.on("--no-embed") {}
|
|
83
|
+
o.on("--no-watch") {}
|
|
84
|
+
end
|
|
85
|
+
parser.parse!(argv)
|
|
86
|
+
cfg = Config.load(opts[:config])
|
|
87
|
+
puts "msnav #{VERSION} — index #{cfg.db_path}"
|
|
88
|
+
Server.new(cfg, host: opts[:host], port: opts[:port],
|
|
89
|
+
service_root: opts[:service_root],
|
|
90
|
+
service_name: opts[:service]).start
|
|
91
|
+
0
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cmd_up(argv)
|
|
95
|
+
opts = { host: "127.0.0.1", port: 8787, timeout: 15.0 }
|
|
96
|
+
parser = OptionParser.new do |o|
|
|
97
|
+
o.banner = "Usage: msnav up [ROOT] [options]"
|
|
98
|
+
o.on("--config PATH") { |v| opts[:config] = v }
|
|
99
|
+
o.on("--host HOST") { |v| opts[:host] = v }
|
|
100
|
+
o.on("--port PORT", Integer) { |v| opts[:port] = v }
|
|
101
|
+
o.on("--timeout SECONDS", Float,
|
|
102
|
+
"Seconds to wait for the daemon to become healthy") { |v| opts[:timeout] = v }
|
|
103
|
+
# passthrough flags kept for coderag compatibility
|
|
104
|
+
o.on("--service-root DIR") { |v| opts[:service_root] = v }
|
|
105
|
+
o.on("--service NAME") { |v| opts[:service] = v }
|
|
106
|
+
o.on("--no-embed") {}
|
|
107
|
+
o.on("--no-watch") {}
|
|
108
|
+
end
|
|
109
|
+
parser.parse!(argv)
|
|
110
|
+
root = argv.shift
|
|
111
|
+
|
|
112
|
+
hub, config, implicit_root, implicit_name = resolve_hub(opts[:config], root)
|
|
113
|
+
service_root = opts[:service_root] || implicit_root
|
|
114
|
+
service_name = opts[:service] || implicit_name
|
|
115
|
+
expected = Ctl.expected_workspace_root(hub)
|
|
116
|
+
url = "http://#{opts[:host]}:#{opts[:port]}"
|
|
117
|
+
|
|
118
|
+
probe = Ctl.probe_health(url)
|
|
119
|
+
if probe.state == "foreign"
|
|
120
|
+
raise Exit.new(Ctl::EXIT_FAIL,
|
|
121
|
+
"#{url} is answering but is not a coderag daemon " \
|
|
122
|
+
"(#{probe.detail}) — is the port taken by something else?")
|
|
123
|
+
end
|
|
124
|
+
if probe.state == "healthy"
|
|
125
|
+
if Ctl.same_root(probe.root, expected)
|
|
126
|
+
puts "already running: #{url} root=#{probe.root} pid=#{probe.pid}"
|
|
127
|
+
return 0
|
|
128
|
+
end
|
|
129
|
+
raise Exit.new(Ctl::EXIT_WRONG_ROOT,
|
|
130
|
+
"a coderag daemon on #{url} serves a different hub:\n" \
|
|
131
|
+
" running: #{probe.root}\n requested: #{expected}\n" \
|
|
132
|
+
"leave it alone or use --port for a second daemon.")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
log_path = Ctl.daemon_log_path(hub)
|
|
136
|
+
_pid, waiter = Ctl.spawn_daemon(hub, config, opts[:host], opts[:port],
|
|
137
|
+
service_root: service_root,
|
|
138
|
+
service_name: service_name,
|
|
139
|
+
log_path: log_path)
|
|
140
|
+
probe = Ctl.wait_healthy(url, waiter, opts[:timeout])
|
|
141
|
+
if probe.state == "healthy"
|
|
142
|
+
if Ctl.same_root(probe.root, expected)
|
|
143
|
+
puts "started: #{url} root=#{probe.root} pid=#{probe.pid}"
|
|
144
|
+
return 0
|
|
145
|
+
end
|
|
146
|
+
raise Exit.new(Ctl::EXIT_WRONG_ROOT,
|
|
147
|
+
"a daemon appeared on #{url} but for a different hub " \
|
|
148
|
+
"(#{probe.root})")
|
|
149
|
+
end
|
|
150
|
+
tail = Ctl.tail_log(log_path)
|
|
151
|
+
raise Exit.new(Ctl::EXIT_FAIL,
|
|
152
|
+
"daemon did not become healthy within " \
|
|
153
|
+
"#{opts[:timeout].round}s" +
|
|
154
|
+
(tail.empty? ? " and wrote nothing to daemon.log" :
|
|
155
|
+
" — last daemon.log lines:\n#{tail}"))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def cmd_status(argv)
|
|
159
|
+
opts = parse_host_port(argv, "status")
|
|
160
|
+
url = "http://#{opts[:host]}:#{opts[:port]}"
|
|
161
|
+
probe = Ctl.probe_health(url)
|
|
162
|
+
if probe.state == "down"
|
|
163
|
+
raise Exit.new(1, "no daemon at #{url} (#{probe.detail})")
|
|
164
|
+
end
|
|
165
|
+
if probe.state == "foreign"
|
|
166
|
+
raise Exit.new(1, "#{url} is answering but is not a coderag daemon " \
|
|
167
|
+
"(#{probe.detail})")
|
|
168
|
+
end
|
|
169
|
+
h = probe.health
|
|
170
|
+
puts "coderag-compatible daemon at #{url}"
|
|
171
|
+
puts " root: #{h['root']}"
|
|
172
|
+
puts " version: #{h['version']} pid: #{h['pid']} " \
|
|
173
|
+
"generation: #{h['generation']}"
|
|
174
|
+
puts " services: #{(h['services'] || []).join(', ')}"
|
|
175
|
+
windows = fetch_windows(url)
|
|
176
|
+
live = windows.count { |w| w["alive"] }
|
|
177
|
+
puts " windows: #{live} live / #{windows.length} registered"
|
|
178
|
+
windows.each do |w|
|
|
179
|
+
mark = w["alive"] ? "live" : "stale (#{w['last_poll_ago']}s)"
|
|
180
|
+
puts " [#{mark}] #{w['window_id']} roots=#{w['roots']}"
|
|
181
|
+
end
|
|
182
|
+
0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def cmd_down(argv)
|
|
186
|
+
opts = parse_host_port(argv, "down")
|
|
187
|
+
url = "http://#{opts[:host]}:#{opts[:port]}"
|
|
188
|
+
probe = Ctl.probe_health(url)
|
|
189
|
+
if probe.state == "down"
|
|
190
|
+
puts "no daemon at #{url}"
|
|
191
|
+
return 0
|
|
192
|
+
end
|
|
193
|
+
if probe.state == "foreign"
|
|
194
|
+
raise Exit.new(1, "#{url} is not a coderag daemon (#{probe.detail}) " \
|
|
195
|
+
"— not touching it")
|
|
196
|
+
end
|
|
197
|
+
pid = probe.pid
|
|
198
|
+
raise Exit.new(1, "daemon health reports no pid — stop it manually") if pid.nil?
|
|
199
|
+
begin
|
|
200
|
+
Process.kill("TERM", pid)
|
|
201
|
+
rescue Errno::ESRCH
|
|
202
|
+
raise Exit.new(1, "daemon reports pid #{pid} but it is not visible " \
|
|
203
|
+
"here — stop it from its own environment " \
|
|
204
|
+
"(host vs container)")
|
|
205
|
+
rescue Errno::EPERM
|
|
206
|
+
raise Exit.new(1, "no permission to signal pid #{pid}")
|
|
207
|
+
end
|
|
208
|
+
20.times do
|
|
209
|
+
if Ctl.probe_health(url).state == "down"
|
|
210
|
+
puts "stopped: pid #{pid}"
|
|
211
|
+
return 0
|
|
212
|
+
end
|
|
213
|
+
sleep 0.25
|
|
214
|
+
end
|
|
215
|
+
raise Exit.new(1, "sent SIGTERM to pid #{pid} but #{url} still answers")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Walks the same resolution ladder `msnav up` uses and shows what each
|
|
219
|
+
# rung sees from THIS directory — run it where `up` failed with exit 2.
|
|
220
|
+
def cmd_doctor(argv)
|
|
221
|
+
opts = parse_host_port(argv, "doctor")
|
|
222
|
+
url = "http://#{opts[:host]}:#{opts[:port]}"
|
|
223
|
+
start = Pathname.new(Dir.pwd)
|
|
224
|
+
puts "msnav #{VERSION} — hub resolution from #{start}"
|
|
225
|
+
|
|
226
|
+
row = ->(k, v) { puts format(" %-17s %s", k, v) }
|
|
227
|
+
row.call("folder name:", "'#{start.basename}' (matched against service " \
|
|
228
|
+
"dir names at each hub, then by file contents)")
|
|
229
|
+
env_cfg = ENV["CODERAG_CONFIG"].to_s
|
|
230
|
+
row.call("CODERAG_CONFIG:", env_cfg.empty? ? "(unset)" : env_cfg)
|
|
231
|
+
walk = Ctl.find_hub_root(start)
|
|
232
|
+
row.call("coderag.yml:", walk ? "found at #{walk}" : "none from here up to /")
|
|
233
|
+
conv = Pathname.new(ENV["CODERAG_HUB"] || "/coderag-hub")
|
|
234
|
+
row.call("CODERAG_HUB:", "#{conv} " +
|
|
235
|
+
((conv + "coderag.yml").file? ? "(usable)" : "(no coderag.yml there)"))
|
|
236
|
+
dd = Datadir.data_dir
|
|
237
|
+
src = ENV["CODERAG_DATA_DIR"].to_s.empty? ? "derived from home #{Dir.home}" : "$CODERAG_DATA_DIR"
|
|
238
|
+
row.call("data dir:", "#{dd} (#{src})")
|
|
239
|
+
|
|
240
|
+
hubs = dd + "hubs"
|
|
241
|
+
if hubs.directory?
|
|
242
|
+
entries = hubs.children.sort.select(&:directory?)
|
|
243
|
+
puts " no hubs inside — index on the HOST first (`coderag index` at the hub)" if entries.empty?
|
|
244
|
+
entries.each do |h|
|
|
245
|
+
db = h + "coderag.db"
|
|
246
|
+
next puts " #{h.basename}: (no coderag.db)" unless db.file?
|
|
247
|
+
names = begin
|
|
248
|
+
con = SQLite3::Database.new(db.to_s, readonly: true)
|
|
249
|
+
begin
|
|
250
|
+
con.execute("SELECT dirname FROM services").map { |r| r[0] }
|
|
251
|
+
ensure
|
|
252
|
+
con.close
|
|
253
|
+
end
|
|
254
|
+
rescue SQLite3::Exception => e
|
|
255
|
+
next puts " #{h.basename}: unreadable (#{e.message})"
|
|
256
|
+
end
|
|
257
|
+
identified = Datadir.identify_service(db, start)
|
|
258
|
+
mark = if identified.nil?
|
|
259
|
+
""
|
|
260
|
+
elsif identified == start.basename.to_s
|
|
261
|
+
" <-- matches this folder by name"
|
|
262
|
+
else
|
|
263
|
+
" <-- matches this folder by content (as #{identified})"
|
|
264
|
+
end
|
|
265
|
+
puts " #{h.basename}: #{names.join(', ')}#{mark}"
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
puts " MISSING — the host's ~/.local/share/coderag is not visible here."
|
|
269
|
+
puts " Mount it into the REMOTE USER's home, or mount anywhere and set $CODERAG_DATA_DIR."
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
probe = Ctl.probe_health(url)
|
|
273
|
+
row.call("daemon #{url}:",
|
|
274
|
+
case probe.state
|
|
275
|
+
when "healthy" then "healthy (root #{probe.root}, pid #{probe.pid})"
|
|
276
|
+
when "foreign" then "something else answers (#{probe.detail})"
|
|
277
|
+
else "down"
|
|
278
|
+
end)
|
|
279
|
+
|
|
280
|
+
begin
|
|
281
|
+
hub, config, service_root, service_name = resolve_hub(nil, nil)
|
|
282
|
+
puts "\nresolution: OK — hub #{hub}, config #{config}" \
|
|
283
|
+
"#{service_root ? ", service root #{service_root}" : ''}" \
|
|
284
|
+
"#{service_name ? " (service #{service_name})" : ''}"
|
|
285
|
+
0
|
|
286
|
+
rescue Exit
|
|
287
|
+
puts "\nresolution: FAILED — `msnav up` from here exits 2. Fixes, best first:"
|
|
288
|
+
puts " * index the hub on the HOST (`coderag index` in the folder containing"
|
|
289
|
+
puts " all services) so this service's files match an index by content"
|
|
290
|
+
puts " * run from the folder that actually holds the service's source"
|
|
291
|
+
puts " (content matching needs its .rb files on disk)"
|
|
292
|
+
puts " * or set $CODERAG_HUB to the hub dir shown above (it holds a coderag.yml),"
|
|
293
|
+
puts " or pass --config <hub-dir>/coderag.yml in msnav.daemonCommand"
|
|
294
|
+
puts " * non-root container? mount the data dir into the remote user's home," \
|
|
295
|
+
" or set $CODERAG_DATA_DIR to the mount target"
|
|
296
|
+
1
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# --------------------------------------------------------------- helpers
|
|
301
|
+
|
|
302
|
+
def parse_host_port(argv, name)
|
|
303
|
+
opts = { host: "127.0.0.1", port: 8787 }
|
|
304
|
+
OptionParser.new do |o|
|
|
305
|
+
o.banner = "Usage: msnav #{name} [options]"
|
|
306
|
+
o.on("--host HOST") { |v| opts[:host] = v }
|
|
307
|
+
o.on("--port PORT", Integer) { |v| opts[:port] = v }
|
|
308
|
+
end.parse!(argv)
|
|
309
|
+
opts
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def fetch_windows(url)
|
|
313
|
+
require "net/http"
|
|
314
|
+
uri = URI.parse("#{url}/api/windows")
|
|
315
|
+
res = Net::HTTP.start(uri.host, uri.port,
|
|
316
|
+
open_timeout: 2, read_timeout: 2) do |http|
|
|
317
|
+
http.get(uri.request_uri)
|
|
318
|
+
end
|
|
319
|
+
res.is_a?(Net::HTTPSuccess) ? JSON.parse(res.body) : []
|
|
320
|
+
rescue StandardError
|
|
321
|
+
[]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Hub root + config for the lifecycle commands: explicit --config wins,
|
|
325
|
+
# then $CODERAG_CONFIG, then a coderag.yml walk-up from ROOT or the cwd,
|
|
326
|
+
# then the conventional container mount ($CODERAG_HUB, default
|
|
327
|
+
# /coderag-hub), then the package data dir (which hub's index matches the
|
|
328
|
+
# service this folder holds — by directory name or file contents, so
|
|
329
|
+
# `/app` style mounts work). In the last two cases the start directory is
|
|
330
|
+
# implicitly the service root (a service-only container). Returns
|
|
331
|
+
# [hub, config_path, implicit_service_root, implicit_service_name] or
|
|
332
|
+
# exits 2 — a daemon must never be rooted inside a single service
|
|
333
|
+
# directory.
|
|
334
|
+
def resolve_hub(config_path, root)
|
|
335
|
+
explicit = config_path
|
|
336
|
+
env = ENV["CODERAG_CONFIG"]
|
|
337
|
+
explicit ||= env if env && !env.empty?
|
|
338
|
+
if explicit
|
|
339
|
+
config = Pathname.new(File.expand_path(explicit))
|
|
340
|
+
unless config.file?
|
|
341
|
+
raise Exit.new(Ctl::EXIT_NO_HUB, "config not found: #{config}")
|
|
342
|
+
end
|
|
343
|
+
return [config.parent, config, nil, nil]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
start = Pathname.new(File.expand_path(root || Dir.pwd))
|
|
347
|
+
start = start.realpath if start.exist?
|
|
348
|
+
hub = Ctl.find_hub_root(start)
|
|
349
|
+
return [hub, hub + "coderag.yml", nil, nil] unless hub.nil?
|
|
350
|
+
|
|
351
|
+
conv = Pathname.new(ENV["CODERAG_HUB"] || "/coderag-hub")
|
|
352
|
+
if (conv + "coderag.yml").file?
|
|
353
|
+
# service-only container: the hub arrives as a .coderag-only mount
|
|
354
|
+
# and this directory holds exactly one service's source
|
|
355
|
+
db = Ctl.resolved_storage(conv) + "coderag.db"
|
|
356
|
+
name = db.file? ? Datadir.identify_service(db, start) : nil
|
|
357
|
+
return [conv, conv + "coderag.yml", start.to_s, name]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# package data dir: a container with only the data-dir mount finds its
|
|
361
|
+
# hub by which index matches the service this folder holds
|
|
362
|
+
matches = Datadir.find_hubs_for_service_root(start)
|
|
363
|
+
if matches.length == 1
|
|
364
|
+
hub, name = matches[0]
|
|
365
|
+
config = hub + "coderag.yml"
|
|
366
|
+
# older index without a snapshot
|
|
367
|
+
config.write("storage_dir: .\n") unless config.file?
|
|
368
|
+
return [hub, config, start.to_s, name]
|
|
369
|
+
end
|
|
370
|
+
if matches.length > 1
|
|
371
|
+
raise Exit.new(Ctl::EXIT_NO_HUB,
|
|
372
|
+
"the service in #{start} is indexed in multiple hubs:\n " +
|
|
373
|
+
matches.map { |h, n| "#{h} (as #{n})" }.join("\n ") +
|
|
374
|
+
"\npass --config <hub-dir>/coderag.yml to pick one.")
|
|
375
|
+
end
|
|
376
|
+
raise Exit.new(Ctl::EXIT_NO_HUB,
|
|
377
|
+
"no coderag.yml found here, in any parent directory, or " \
|
|
378
|
+
"at the conventional hub mount (#{conv}); and no indexed " \
|
|
379
|
+
"hub in #{Datadir.data_dir} matches the service in " \
|
|
380
|
+
"#{start}.\nIndex the hub first — on the HOST, in the " \
|
|
381
|
+
"folder containing all services, run `coderag index` — " \
|
|
382
|
+
"and make sure the data dir (~/.local/share/coderag) is " \
|
|
383
|
+
"mounted into this container." \
|
|
384
|
+
"\nRun `msnav doctor` here to see what each resolution " \
|
|
385
|
+
"step found.")
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|